diff --git a/.agents/skills/copywriting/SKILL.md b/.agents/skills/copywriting/SKILL.md new file mode 100644 index 000000000..ac03fdcc4 --- /dev/null +++ b/.agents/skills/copywriting/SKILL.md @@ -0,0 +1,256 @@ +--- +name: copywriting +description: When the user wants to write, rewrite, or improve marketing copy for any page — including homepage, landing pages, pricing pages, feature pages, about pages, or product pages. Also use when the user says "write copy for," "improve this copy," "rewrite this page," "marketing copy," "headline help," "CTA copy," "value proposition," "tagline," "subheadline," "hero section copy," "above the fold," "this copy is weak," "make this more compelling," or "help me describe my product." Use this whenever someone is working on website text that needs to persuade or convert. For email copy, see emails. For popup copy, see popups. For editing existing copy, see copy-editing. For the offer underneath the copy (bonuses, guarantees, value framing), see offers. +metadata: + version: 2.0.2 +--- + +# Copywriting + +You are an expert conversion copywriter. Your goal is to write marketing copy that is clear, compelling, and drives action. + +## Before Writing + +**Check for product marketing context first:** +If `.agents/product-marketing.md` exists (or `.claude/product-marketing.md`, or the legacy `product-marketing-context.md` filename, in older setups), read it before asking questions. Use that context and only ask for information not already covered or specific to this task. + +Gather this context (ask if not provided): + +### 1. Page Purpose +- What type of page? (homepage, landing page, pricing, feature, about) +- What is the ONE primary action you want visitors to take? + +### 2. Audience +- Who is the ideal customer? +- What problem are they trying to solve? +- What objections or hesitations do they have? +- What language do they use to describe their problem? + +### 3. Product/Offer +- What are you selling or offering? +- What makes it different from alternatives? +- What's the key transformation or outcome? +- Any proof points (numbers, testimonials, case studies)? + +### 4. Context +- Where is traffic coming from? (ads, organic, email) +- What do visitors already know before arriving? + +--- + +## Copywriting Principles + +### Clarity Over Cleverness +If you have to choose between clear and creative, choose clear. Clarity is not just tidier — it converts: clearer positioning and copy is associated with +81% conversions, a 38% shorter sales cycle, 28% lower CAC, and 175% more referrals. When a reader has to decode your line, you've lost them. + +**For message-market fit tools** — the "Now you can" test, the Human Action Model (discomfort → vision → path), the Perception Gap, and the clarity metrics: See [references/copy-frameworks.md](references/copy-frameworks.md#clarity--message-market-fit) + +### Benefits Over Features +Features: What it does. Benefits: What that means for the customer. + +### Specificity Over Vagueness +- Vague: "Save time on your workflow" +- Specific: "Cut your weekly reporting from 4 hours to 15 minutes" + +### Customer Language Over Company Language +Use words your customers use. Mirror voice-of-customer from reviews, interviews, support tickets. + +### One Idea Per Section +Each section should advance one argument. Build a logical flow down the page. + +--- + +## Writing Style Rules + +### Core Principles + +1. **Simple over complex** — "Use" not "utilize," "help" not "facilitate" +2. **Specific over vague** — Avoid "streamline," "optimize," "innovative" +3. **Active over passive** — "We generate reports" not "Reports are generated" +4. **Confident over qualified** — Remove "almost," "very," "really" +5. **Show over tell** — Describe the outcome instead of using adverbs +6. **Honest over sensational** — Fabricated statistics or testimonials erode trust and create legal liability + +### Quick Quality Check + +- Jargon that could confuse outsiders? +- Sentences trying to do too much? +- Passive voice constructions? +- Exclamation points? (remove them) +- Marketing buzzwords without substance? + +For thorough line-by-line review, use the **copy-editing** skill after your draft. + +--- + +## Best Practices + +### Be Direct +Get to the point. Don't bury the value in qualifications. + +❌ Slack lets you share files instantly, from documents to images, directly in your conversations + +✅ Need to share a screenshot? Send as many documents, images, and audio files as your heart desires. + +### Use Rhetorical Questions +Questions engage readers and make them think about their own situation. +- "Hate returning stuff to Amazon?" +- "Tired of chasing approvals?" + +### Use Analogies When Helpful +Analogies make abstract concepts concrete and memorable. + +### Pepper in Humor (When Appropriate) +Puns and wit make copy memorable—but only if it fits the brand and doesn't undermine clarity. + +--- + +## Page Structure Framework + +### Above the Fold + +**Headline** +- Your single most important message +- Communicate core value proposition +- Specific > generic + +**Example formulas:** +- "{Achieve outcome} without {pain point}" +- "The {category} for {audience}" +- "Never {unpleasant event} again" +- "{Question highlighting main pain point}" + +**For comprehensive headline formulas**: See [references/copy-frameworks.md](references/copy-frameworks.md) + +**Structure the hero as a transformation** — current discomfort → better vision → path to action (the Human Action Model), then run every headline through the "Now you can" test. See [references/copy-frameworks.md](references/copy-frameworks.md#clarity--message-market-fit) + +**For natural transition phrases**: See [references/natural-transitions.md](references/natural-transitions.md) + +**Subheadline** +- Expands on headline +- Adds specificity +- 1-2 sentences max + +**Primary CTA** +- Action-oriented button text +- Communicate what they get: "Start Free Trial" > "Sign Up" + +### Core Sections + +| Section | Purpose | +|---------|---------| +| Social Proof | Build credibility (logos, stats, testimonials) | +| Problem/Pain | Show you understand their situation | +| Solution/Benefits | Connect to outcomes (3-5 key benefits) | +| How It Works | Reduce perceived complexity (3-4 steps) | +| Objection Handling | FAQ, comparisons, guarantees | +| Final CTA | Recap value, repeat CTA, risk reversal | + +**For detailed section types and page templates**: See [references/copy-frameworks.md](references/copy-frameworks.md) + +--- + +## CTA Copy Guidelines + +**Weak CTAs (avoid):** +- Submit, Sign Up, Learn More, Click Here, Get Started + +**Strong CTAs (use):** +- Start Free Trial +- Get [Specific Thing] +- See [Product] in Action +- Create Your First [Thing] +- Download the Guide + +**Formula:** [Action Verb] + [What They Get] + [Qualifier if needed] + +Examples: +- "Start My Free Trial" +- "Get the Complete Checklist" +- "See Pricing for My Team" + +--- + +## Page-Specific Guidance + +### Homepage +- Serve multiple audiences without being generic +- Lead with broadest value proposition +- Provide clear paths for different visitor intents + +### Landing Page +- Single message, single CTA +- Match headline to ad/traffic source +- Complete argument on one page + +### Pricing Page +- Help visitors choose the right plan +- Address "which is right for me?" anxiety +- Make recommended plan obvious + +### Feature Page +- Connect feature → benefit → outcome +- Show use cases and examples +- Clear path to try or buy + +### About Page +- Tell the story of why you exist +- Connect mission to customer benefit +- Still include a CTA + +--- + +## Voice and Tone + +Before writing, establish: + +**Formality level:** +- Casual/conversational +- Professional but friendly +- Formal/enterprise + +**Brand personality:** +- Playful or serious? +- Bold or understated? +- Technical or accessible? + +Maintain consistency, but adjust intensity: +- Headlines can be bolder +- Body copy should be clearer +- CTAs should be action-oriented + +--- + +## Output Format + +When writing copy, provide: + +### Page Copy +Organized by section: +- Headline, Subheadline, CTA +- Section headers and body copy +- Secondary CTAs + +### Annotations +For key elements, explain: +- Why you made this choice +- What principle it applies + +### Alternatives +For headlines and CTAs, provide 2-3 options: +- Option A: [copy] — [rationale] +- Option B: [copy] — [rationale] + +### Meta Content (if relevant) +- Page title (for SEO) +- Meta description + +--- + +## Related Skills + +- **copy-editing**: For polishing existing copy (use after your draft) +- **cro**: If page structure/strategy needs work, not just copy +- **emails**: For email copywriting +- **popups**: For popup and modal copy +- **ab-testing**: To test copy variations \ No newline at end of file diff --git a/.agents/skills/copywriting/evals/evals.json b/.agents/skills/copywriting/evals/evals.json new file mode 100644 index 000000000..95a862825 --- /dev/null +++ b/.agents/skills/copywriting/evals/evals.json @@ -0,0 +1,126 @@ +{ + "skill_name": "copywriting", + "evals": [ + { + "id": 1, + "prompt": "Write homepage copy for a SaaS tool that automates employee onboarding. Target audience is HR directors at mid-size companies (200-2000 employees). Main differentiator is that it integrates with all major HRIS systems and cuts onboarding time from 2 weeks to 2 days.", + "expected_output": "Should check for product-marketing.md first. Should write full page copy organized by section: Headline, Subheadline, CTA (above the fold), then Social Proof, Problem/Pain, Solution/Benefits, How It Works, Objection Handling, and Final CTA. Should follow copywriting principles: clarity over cleverness, benefits over features, specificity (use the '2 weeks to 2 days' stat), customer language. Headline should communicate core value proposition. CTAs should be action-oriented ('Start Free Trial' not 'Submit'). Should provide 2-3 headline alternatives with rationale. Should include annotations explaining key copy choices. Should include meta content (SEO page title and meta description).", + "assertions": [ + "Checks for product-marketing.md", + "Writes full page copy organized by section", + "Includes Headline, Subheadline, and CTA above the fold", + "Includes Social Proof, Problem/Pain, Solution/Benefits, How It Works sections", + "Uses the '2 weeks to 2 days' specificity in copy", + "CTAs are action-oriented, not generic", + "Provides 2-3 headline alternatives with rationale", + "Includes annotations explaining copy choices", + "Includes meta content (SEO title and meta description)" + ], + "files": [] + }, + { + "id": 2, + "prompt": "Rewrite this headline: 'An Innovative AI-Powered Platform for Streamlined Business Operations' — it's for a B2B SaaS tool that helps small businesses manage invoicing and payments.", + "expected_output": "Should identify problems: jargon ('innovative,' 'AI-powered,' 'streamlined,' 'business operations'), too vague, company language not customer language. Should apply copywriting principles — specificity over vagueness, benefits over features, customer language over company language. Should provide 2-3 alternative headlines using formulas like '{Achieve outcome} without {pain point}' or 'The {category} for {audience}'. Each alternative should include rationale. Should also suggest a subheadline that adds specificity.", + "assertions": [ + "Identifies jargon in original headline", + "Identifies vagueness as a problem", + "Identifies company language vs customer language issue", + "Provides 2-3 alternative headlines", + "Alternatives use headline formulas from the skill", + "Each alternative includes rationale", + "Suggests a subheadline" + ], + "files": [] + }, + { + "id": 3, + "prompt": "i need copy for my pricing page. we have three plans: starter ($29/mo), pro ($79/mo), business ($199/mo). it's a social media scheduling tool for marketers", + "expected_output": "Should trigger on the casual phrasing. Should ask or infer audience context. Should apply Pricing Page guidance: help visitors choose the right plan, address 'which is right for me?' anxiety, make recommended plan obvious. Should write plan names, descriptions, feature lists with benefit-oriented copy (not just feature names). Should include a page headline that addresses the pricing decision. CTAs should be specific per plan. Should handle objection handling (FAQ copy). Should provide alternatives for key elements.", + "assertions": [ + "Triggers on casual phrasing", + "Applies Pricing Page guidance", + "Addresses 'which plan is right for me' anxiety", + "Makes recommended plan obvious", + "Writes benefit-oriented feature copy, not just feature names", + "Includes page headline", + "CTAs are specific per plan", + "Includes FAQ or objection handling copy", + "Provides alternatives for key elements" + ], + "files": [] + }, + { + "id": 4, + "prompt": "Write copy for our About page. We're a 3-person startup that built a developer tool for database migrations. Founded because we kept losing data during migrations at our last jobs. Tone should be professional but human.", + "expected_output": "Should apply About Page guidance: tell the story of why you exist, connect mission to customer benefit, still include a CTA. Should adapt voice and tone to 'professional but human' as specified. Should tell the founder origin story authentically. Should connect the personal pain to the customer's pain. Should include a CTA even on the About page. Copy should follow style rules: active voice, confident, specific. Should NOT be overly corporate or generic.", + "assertions": [ + "Applies About Page guidance", + "Tells the story of why the company exists", + "Connects mission to customer benefit", + "Includes a CTA", + "Adapts tone to professional but human", + "Uses the founder origin story", + "Connects personal pain to customer pain", + "Uses active voice", + "Avoids corporate jargon" + ], + "files": [] + }, + { + "id": 5, + "prompt": "Can you improve this CTA? We currently have 'Learn More' on our feature page for our analytics dashboard product.", + "expected_output": "Should immediately identify 'Learn More' as a weak CTA per the guidelines. Should apply the CTA formula: [Action Verb] + [What They Get] + [Qualifier]. Should provide 2-3 strong alternatives like 'See the Dashboard in Action,' 'Start Your Free Trial,' or 'Explore Analytics Features.' Each alternative should include rationale and context for when it works best. Should also consider CTA hierarchy — whether this is a primary or secondary CTA, and suggest complementary CTAs if relevant.", + "assertions": [ + "Identifies 'Learn More' as a weak CTA", + "Applies the CTA formula from the skill", + "Provides 2-3 strong alternatives", + "Each alternative includes rationale", + "Considers CTA hierarchy (primary vs secondary)", + "Suggests complementary CTAs" + ], + "files": [] + }, + { + "id": 6, + "prompt": "Write me a 5-email welcome sequence for new trial users of our project management tool.", + "expected_output": "Should recognize this is an email copywriting task, not page copywriting. Should defer to or cross-reference the emails skill, which specifically handles email sequences, drip campaigns, and lifecycle emails. May provide brief general guidance but should make clear that emails is the right skill for this task.", + "assertions": [ + "Recognizes this as email sequence work", + "References or defers to emails skill", + "Does not attempt to write a full email sequence using page copywriting patterns" + ], + "files": [] + }, + { + "id": 7, + "prompt": "Review this copy and tell me what's wrong: 'We are extremely excited to announce our revolutionary, cutting-edge platform that will totally transform how businesses optimize their workflows! Sign up now!!'", + "expected_output": "Should apply the Quick Quality Check. Should identify: exclamation points (remove them), marketing buzzwords without substance ('revolutionary,' 'cutting-edge,' 'totally transform,' 'optimize'), passive/weak constructions ('we are excited to announce'), vague language ('workflows'). Should apply writing style rules: simple over complex, specific over vague, confident over qualified, show over tell. Should rewrite the copy following these principles. Should provide 2-3 alternatives.", + "assertions": [ + "Identifies exclamation point overuse", + "Identifies marketing buzzwords without substance", + "Identifies vague language", + "Applies writing style rules", + "Rewrites the copy following principles", + "Provides alternatives", + "Result is specific, clear, and jargon-free" + ], + "files": [] + }, + { + "id": 8, + "prompt": "Write above-the-fold copy for a calendar scheduling tool. Our differentiator is that the recipient gets to overlay their own calendar on the invite, so picking a time feels fair to both people instead of one-sided. Same product needs to work for indie founders AND for enterprise ops teams.", + "expected_output": "Should structure the hero using the Human Action Model transformation spine: current discomfort (the awkwardness of sending a one-sided scheduling link), better vision (scheduling that feels considerate to both people), and path to action (the overlay mechanic + a specific CTA). Should run headline candidates through the 'Now you can' test and prefer lines that are compelling and true. Should reference or echo the SavvyCal awkward-link insight ('You shouldn't have to feel awkward sending out your scheduling link') as the message-market-fit model. Should surface the Perception Gap: the same benefit reads differently by risk tolerance, so it should provide a value-prop swap — a founder-facing framing (speed, no sales calls) and an enterprise-facing framing (security, SLAs, reliability) rather than one averaged, mushy message. Should favor clarity over cleverness and provide 2-3 headline alternatives with rationale.", + "assertions": [ + "Structures the hero as discomfort -> vision -> path (Human Action Model)", + "Applies the 'Now you can' test to headline candidates", + "References the SavvyCal awkward-link message-market-fit insight", + "Surfaces the Perception Gap between segments", + "Provides a value-prop swap: founder framing vs enterprise framing", + "Favors clarity over cleverness", + "Provides 2-3 headline alternatives with rationale" + ], + "files": [] + } + ] + } \ No newline at end of file diff --git a/.agents/skills/copywriting/references/copy-frameworks.md b/.agents/skills/copywriting/references/copy-frameworks.md new file mode 100644 index 000000000..53a3ddac6 --- /dev/null +++ b/.agents/skills/copywriting/references/copy-frameworks.md @@ -0,0 +1,433 @@ +# Copy Frameworks Reference + +Headline formulas, page section types, and structural templates. + +## Contents +- Headline Formulas (outcome-focused, problem-focused, audience-focused, differentiation-focused, proof-focused, additional formulas) +- Landing Page Section Types (core sections, supporting sections) +- Page Structure Templates (feature-heavy page, varied engaging page, compact landing page, enterprise/B2B landing page, product launch page) +- Section Writing Tips (problem section, benefits section, how it works section, testimonial selection) +- Clarity & Message-Market Fit (the "Now you can" test, Human Action Model, the Perception Gap, the SavvyCal case, clarity metrics) + +## Headline Formulas + +### Outcome-Focused + +**{Achieve desirable outcome} without {pain point}** +> Understand how users are really experiencing your site without drowning in numbers + +**{Achieve desirable outcome} by {how product makes it possible}** +> Generate more leads by seeing which companies visit your site + +**Turn {input} into {outcome}** +> Turn your hard-earned sales into repeat customers + +**[Achieve outcome] in [timeframe]** +> Get your tax refund in 10 days + +--- + +### Problem-Focused + +**Never {unpleasant event} again** +> Never miss a sales opportunity again + +**{Question highlighting the main pain point}** +> Hate returning stuff to Amazon? + +**Stop [pain]. Start [pleasure].** +> Stop chasing invoices. Start getting paid on time. + +--- + +### Audience-Focused + +**{Key feature/product type} for {target audience}** +> Advanced analytics for Shopify e-commerce + +**{Key feature/product type} for {target audience} to {what it's used for}** +> An online whiteboard for teams to ideate and brainstorm together + +**You don't have to {skills or resources} to {achieve desirable outcome}** +> With Ahrefs, you don't have to be an SEO pro to rank higher and get more traffic + +--- + +### Differentiation-Focused + +**The {opposite of usual process} way to {achieve desirable outcome}** +> The easiest way to turn your passion into income + +**The [category] that [key differentiator]** +> The CRM that updates itself + +--- + +### Proof-Focused + +**[Number] [people] use [product] to [outcome]** +> 50,000 marketers use Drip to send better emails + +**{Key benefit of your product}** +> Sound clear in online meetings + +--- + +### Additional Formulas + +**The simple way to {outcome}** +> The simple way to track your time + +**Finally, {category} that {benefit}** +> Finally, accounting software that doesn't suck + +**{Outcome} without {common pain}** +> Build your website without writing code + +**Get {benefit} from your {thing}** +> Get more revenue from your existing traffic + +**{Action verb} your {thing} like {admirable example}** +> Market your SaaS like a Fortune 500 + +**What if you could {desirable outcome}?** +> What if you could close deals 30% faster? + +**Everything you need to {outcome}** +> Everything you need to launch your course + +**The {adjective} {category} built for {audience}** +> The lightweight CRM built for startups + +--- + +## Landing Page Section Types + +### Core Sections + +**Hero (Above the Fold)** +- Headline + subheadline +- Primary CTA +- Supporting visual (product screenshot, hero image) +- Optional: Social proof bar + +**Social Proof Bar** +- Customer logos (recognizable > many) +- Key metric ("10,000+ teams") +- Star rating with review count +- Short testimonial snippet + +**Problem/Pain Section** +- Articulate their problem better than they can +- Create recognition ("that's exactly my situation") +- Hint at cost of not solving it + +**Solution/Benefits Section** +- Bridge from problem to your solution +- 3-5 key benefits (not 10) +- Each: headline + explanation + proof if available + +**How It Works** +- 3-4 numbered steps +- Reduces perceived complexity +- Each step: action + outcome + +**Final CTA Section** +- Recap value proposition +- Repeat primary CTA +- Risk reversal (guarantee, free trial) + +--- + +### Supporting Sections + +**Testimonials** +- Full quotes with names, roles, companies +- Photos when possible +- Specific results over vague praise +- Formats: quote cards, video, tweet embeds + +**Case Studies** +- Problem → Solution → Results +- Specific metrics and outcomes +- Customer name and context +- Can be snippets with "Read more" links + +**Use Cases** +- Different ways product is used +- Helps visitors self-identify +- "For marketers who need X" format + +**Personas / "Built For" Sections** +- Explicitly call out target audience +- "Perfect for [role]" blocks +- Addresses "Is this for me?" question + +**FAQ Section** +- Address common objections +- Good for SEO +- Reduces support burden +- 5-10 most common questions + +**Comparison Section** +- vs. competitors (name them or don't) +- vs. status quo (spreadsheets, manual processes) +- Tables or side-by-side format + +**Integrations / Partners** +- Logos of tools you connect with +- "Works with your stack" messaging +- Builds credibility + +**Founder Story / Manifesto** +- Why you built this +- What you believe +- Emotional connection +- Differentiates from faceless competitors + +**Demo / Product Tour** +- Interactive demos +- Video walkthroughs +- GIF previews +- Shows product in action + +**Pricing Preview** +- Teaser even on non-pricing pages +- Starting price or "from $X/mo" +- Moves decision-makers forward + +**Guarantee / Risk Reversal** +- Money-back guarantee +- Free trial terms +- "Cancel anytime" +- Reduces friction + +**Stats Section** +- Key metrics that build credibility +- "10,000+ customers" +- "4.9/5 rating" +- "$2M saved for customers" + +--- + +## Page Structure Templates + +### Feature-Heavy Page (Weak) + +``` +1. Hero +2. Feature 1 +3. Feature 2 +4. Feature 3 +5. Feature 4 +6. CTA +``` + +This is a list, not a persuasive narrative. + +--- + +### Varied, Engaging Page (Strong) + +``` +1. Hero with clear value prop +2. Social proof bar (logos or stats) +3. Problem/pain section +4. How it works (3 steps) +5. Key benefits (2-3, not 10) +6. Testimonial +7. Use cases or personas +8. Comparison to alternatives +9. Case study snippet +10. FAQ +11. Final CTA with guarantee +``` + +This tells a story and addresses objections. + +--- + +### Compact Landing Page + +``` +1. Hero (headline, subhead, CTA, image) +2. Social proof bar +3. 3 key benefits with icons +4. Testimonial +5. How it works (3 steps) +6. Final CTA with guarantee +``` + +Good for ad landing pages where brevity matters. + +--- + +### Enterprise/B2B Landing Page + +``` +1. Hero (outcome-focused headline) +2. Logo bar (recognizable companies) +3. Problem section (business pain) +4. Solution overview +5. Use cases by role/department +6. Security/compliance section +7. Integration logos +8. Case study with metrics +9. ROI/value section +10. Contact/demo CTA +``` + +Addresses enterprise buyer concerns. + +--- + +### Product Launch Page + +``` +1. Hero with launch announcement +2. Video demo or walkthrough +3. Feature highlights (3-5) +4. Before/after comparison +5. Early testimonials +6. Launch pricing or early access offer +7. CTA with urgency +``` + +Good for ProductHunt, launches, or announcements. + +--- + +## Section Writing Tips + +### Problem Section + +Start with phrases like: +- "You know the feeling..." +- "If you're like most [role]..." +- "Every day, [audience] struggles with..." +- "We've all been there..." + +Then describe: +- The specific frustration +- The time/money wasted +- The impact on their work/life + +### Benefits Section + +For each benefit, include: +- **Headline**: The outcome they get +- **Body**: How it works (1-2 sentences) +- **Proof**: Number, testimonial, or example (optional) + +### How It Works Section + +Each step should be: +- **Numbered**: Creates sense of progress +- **Simple verb**: "Connect," "Set up," "Get" +- **Outcome-oriented**: What they get from this step + +Example: +1. Connect your tools (takes 2 minutes) +2. Set your preferences +3. Get automated reports every Monday + +### Testimonial Selection + +Best testimonials include: +- Specific results ("increased conversions by 32%") +- Before/after context ("We used to spend hours...") +- Role + company for credibility +- Something quotable and specific + +Avoid testimonials that just say: +- "Great product!" +- "Love it!" +- "Easy to use!" + +--- + +## Clarity & Message-Market Fit + +Headline formulas give you the shape of a line. These tools tell you whether the line is actually *working* — whether it's clear, whether it maps to how the reader already thinks, and whether it lands with the right person. Positioning is the prologue to your novel: it sets up everything that follows. Get it clear and the rest of the page writes itself. + +### The "Now you can" Test + +A fast gut-check for any headline or benefit line. Mentally prefix it with **"Now you can…"**. If the result is both **compelling** and **true**, the line is doing its job. If it reads as vague, obvious, or a stretch, rewrite it. + +The test works because "Now you can…" forces the copy into the reader's world — it has to name a concrete new ability they didn't have before. Feature-speak and buzzwords collapse under it. + +| Original line | "Now you can…" version | Verdict | +|---------------|------------------------|---------| +| "Powerful analytics platform" | Now you can… have a powerful analytics platform | Fails — not a new ability, just a description | +| "See which companies visit your site" | Now you can… see which companies visit your site | Works — compelling + true | +| "Streamline your workflow" | Now you can… streamline your workflow | Fails — vague, unfalsifiable | +| "Send unlimited docs, images, and audio in one place" | Now you can… send unlimited docs, images, and audio in one place | Works — concrete + true | + +Use it as a filter, not a formula: draft with the headline formulas above, then run each candidate through "Now you can…" and keep the survivors. + +### The Human Action Model (landing-page narrative spine) + +Ludwig von Mises' Human Action Model explains *why* anyone acts: a person acts only when three things line up. Every above-the-fold that converts follows the same three-beat spine: + +1. **Current discomfort** — the felt problem, named in the reader's own words. They have to recognize their situation ("that's exactly me"). +2. **Better vision** — a clearly imagined, more satisfying state. What life looks like once the discomfort is gone. +3. **Path to action** — the belief that *this specific step* closes the gap between the two. The product is the bridge, and the CTA is how they cross it. + +Miss any beat and the reader stalls. No discomfort = no reason to move. No vision = no destination. No path = no reason to believe *you're* the way there. + +**Mapping it onto the hero:** + +| Beat | Where it usually lives | Example | +|------|------------------------|---------| +| Current discomfort | Eyebrow, subhead, or problem-framed headline | "You shouldn't have to feel awkward sending out your scheduling link" | +| Better vision | Headline or subhead | "Scheduling that feels considerate, not one-sided" | +| Path to action | CTA + supporting proof | "Start scheduling free" | + +This is the transformation spine underneath the "6 essential sections" of a landing page — hero, social proof, problem, solution, how-it-works, and final CTA. The hero states the transformation; the rest of the page substantiates each beat. + +### The Perception Gap + +The same benefit can read as a **selling point to one segment and a red flag to another**. The gap is between what *you* think you're saying and what a given reader hears through their own risk tolerance. + +The fix isn't softer copy — it's **matching the value prop to the reader's risk tolerance**. Segment first, then swap the framing. + +| Benefit as written | Startup / early-adopter hears | Enterprise / risk-averse hears | +|--------------------|-------------------------------|--------------------------------| +| "Move fast — ship in a weekend" | Speed, momentum (✅) | Immature, unstable (🚩) | +| "Brand-new approach" | Innovative edge (✅) | Unproven, risky (🚩) | +| "Enterprise-grade security & SLAs" | Bloated, slow, expensive (🚩) | Safe, trustworthy (✅) | +| "Trusted by the Fortune 500" | Not built for me (🚩) | Proven, de-risked (✅) | + +**Value-prop swap in practice** — same product, two audiences: + +- *Startup landing page:* "Ship your first integration this afternoon. No sales calls, no procurement." +- *Enterprise landing page:* "SOC 2 Type II, 99.99% uptime SLA, and a named implementation lead. Roll out with confidence." + +When a page has to serve both, don't average them into mush — segment the traffic (separate pages, or a persona split) and let each read its own version of the truth. + +### Worked Example — SavvyCal (message-market fit) + +SavvyCal (a scheduling tool) originally led with feature-forward copy. They rewrote the hero around a single felt discomfort: + +> **"You shouldn't have to feel awkward sending out your scheduling link."** + +That one line **roughly tripled (3×) conversions**. It works because it hits all three beats of the Human Action Model at once: + +- **Discomfort:** the small social awkwardness of "here's my link, pick a time" — named exactly as users feel it. +- **Vision:** scheduling that feels considerate to *both* people. +- **Path:** SavvyCal's overlay-your-calendar mechanic is the bridge, so the CTA feels like the obvious next step. + +The lesson: message-market fit beats feature lists. The winning line wasn't cleverer — it named a real feeling the reader hadn't heard a scheduling tool acknowledge before. Run your own hero through "Now you can…" and the Human Action Model to find that line. + +### Clarity Beats Cleverness (the metrics) + +When teams measure it, clarity — not wit — is what moves the numbers. Clearer positioning and copy is associated with: + +- **+81% conversions** +- **−38% sales cycle** (shorter time to close) +- **−28% CAC** (lower customer acquisition cost) +- **+175% referrals** + +The mechanism: clear copy lets the *right* buyer self-qualify fast and the wrong one bounce early, so every downstream metric improves. Clever copy that requires decoding does the opposite — it adds a comprehension tax at the exact moment attention is scarcest. + +**Practical rule:** if a reader has to pause to figure out what you mean, you've already lost. When forced to choose between a clever line and a clear one, ship the clear one — then use the tests above ("Now you can…", the Human Action Model, the Perception Gap) to make the clear line compelling too. diff --git a/.agents/skills/copywriting/references/natural-transitions.md b/.agents/skills/copywriting/references/natural-transitions.md new file mode 100644 index 000000000..2811575fa --- /dev/null +++ b/.agents/skills/copywriting/references/natural-transitions.md @@ -0,0 +1,272 @@ +# Natural Transitions + +Transitional phrases to guide readers through your content. Good signposting improves readability, user engagement, and helps search engines understand content structure. + +Adapted from: University of Manchester Academic Phrasebank (2023), Plain English Campaign, web content best practices + +--- + +## Contents +- Previewing Content Structure +- Introducing a New Topic +- Referring Back +- Moving Between Sections +- Indicating Addition +- Indicating Contrast +- Indicating Similarity +- Indicating Cause and Effect +- Giving Examples +- Emphasising Key Points +- Providing Evidence (neutral attribution, expert quotes, supporting claims) +- Summarising Sections +- Concluding Content +- Question-Based Transitions +- List Introductions +- Hedging Language +- Best Practice Guidelines +- Transitions to Avoid (AI Tells) + +## Previewing Content Structure + +Use to orient readers and set expectations: + +- Here's what we'll cover... +- This guide walks you through... +- Below, you'll find... +- We'll start with X, then move to Y... +- First, let's look at... +- Let's break this down step by step. +- The sections below explain... + +--- + +## Introducing a New Topic + +- When it comes to X,... +- Regarding X,... +- Speaking of X,... +- Now let's talk about X. +- Another key factor is... +- X is worth exploring because... + +--- + +## Referring Back + +Use to connect ideas and reinforce key points: + +- As mentioned earlier,... +- As we covered above,... +- Remember when we discussed X? +- Building on that point,... +- Going back to X,... +- Earlier, we explained that... + +--- + +## Moving Between Sections + +- Now let's look at... +- Next up:... +- Moving on to... +- With that covered, let's turn to... +- Now that you understand X, here's Y. +- That brings us to... + +--- + +## Indicating Addition + +- Also,... +- Plus,... +- On top of that,... +- What's more,... +- Another benefit is... +- Beyond that,... +- In addition,... +- There's also... + +**Note:** Use "moreover" and "furthermore" sparingly. They can sound AI-generated when overused. + +--- + +## Indicating Contrast + +- However,... +- But,... +- That said,... +- On the flip side,... +- In contrast,... +- Unlike X, Y... +- While X is true, Y... +- Despite this,... + +--- + +## Indicating Similarity + +- Similarly,... +- Likewise,... +- In the same way,... +- Just like X, Y also... +- This mirrors... +- The same applies to... + +--- + +## Indicating Cause and Effect + +- So,... +- This means... +- As a result,... +- That's why... +- Because of this,... +- This leads to... +- The outcome?... +- Here's what happens:... + +--- + +## Giving Examples + +- For example,... +- For instance,... +- Here's an example:... +- Take X, for instance. +- Consider this:... +- A good example is... +- To illustrate,... +- Like when... +- Say you want to... + +--- + +## Emphasising Key Points + +- Here's the key takeaway:... +- The important thing is... +- What matters most is... +- Don't miss this:... +- Pay attention to... +- This is critical:... +- The bottom line?... + +--- + +## Providing Evidence + +Use when citing sources, data, or expert opinions: + +### Neutral attribution +- According to [Source],... +- [Source] reports that... +- Research shows that... +- Data from [Source] indicates... +- A study by [Source] found... + +### Expert quotes +- As [Expert] puts it,... +- [Expert] explains,... +- In the words of [Expert],... +- [Expert] notes that... + +### Supporting claims +- This is backed by... +- Evidence suggests... +- The numbers confirm... +- This aligns with findings from... + +--- + +## Summarising Sections + +- To recap,... +- Here's the short version:... +- In short,... +- The takeaway?... +- So what does this mean?... +- Let's pull this together:... +- Quick summary:... + +--- + +## Concluding Content + +- Wrapping up,... +- The bottom line is... +- Here's what to do next:... +- To sum up,... +- Final thoughts:... +- Ready to get started?... +- Now it's your turn. + +**Note:** Avoid "In conclusion" at the start of a paragraph. It's overused and signals AI writing. + +--- + +## Question-Based Transitions + +Useful for conversational tone and featured snippet optimization: + +- So what does this mean for you? +- But why does this matter? +- How do you actually do this? +- What's the catch? +- Sound complicated? It's not. +- Wondering where to start? +- Still not sure? Here's the breakdown. + +--- + +## List Introductions + +For numbered lists and step-by-step content: + +- Here's how to do it: +- Follow these steps: +- The process is straightforward: +- Here's what you need to know: +- Key things to consider: +- The main factors are: + +--- + +## Hedging Language + +For claims that need qualification or aren't absolute: + +- may, might, could +- tends to, generally +- often, usually, typically +- in most cases +- it appears that +- evidence suggests +- this can help +- many experts believe + +--- + +## Best Practice Guidelines + +1. **Match tone to audience**: B2B content can be slightly more formal; B2C often benefits from conversational transitions +2. **Vary your transitions**: Repeating the same phrase gets noticed (and not in a good way) +3. **Don't over-signpost**: Trust your reader; every sentence doesn't need a transition +4. **Use for scannability**: Transitions at paragraph starts help skimmers navigate +5. **Keep it natural**: Read aloud; if it sounds forced, simplify +6. **Front-load key info**: Put the important word or phrase early in the transition + +--- + +## Transitions to Avoid (AI Tells) + +These phrases are overused in AI-generated content: + +- "That being said,..." +- "It's worth noting that..." +- "At its core,..." +- "In today's digital landscape,..." +- "When it comes to the realm of..." +- "This begs the question..." +- "Let's delve into..." + +See the seo-audit skill's `references/ai-writing-detection.md` for a complete list of AI writing tells. \ No newline at end of file diff --git a/.agents/skills/humanizer/SKILL.md b/.agents/skills/humanizer/SKILL.md new file mode 100644 index 000000000..b0865c7d1 --- /dev/null +++ b/.agents/skills/humanizer/SKILL.md @@ -0,0 +1,595 @@ +--- +name: humanizer +version: 2.5.1 +description: | + Remove signs of AI-generated writing from text. Use when editing or reviewing + text to make it sound more natural and human-written. Based on Wikipedia's + comprehensive "Signs of AI writing" guide. Detects and fixes patterns including: + inflated symbolism, promotional language, superficial -ing analyses, vague + attributions, em dash overuse, rule of three, AI vocabulary words, passive + voice, negative parallelisms, and filler phrases. +license: MIT +compatibility: Codex opencode +allowed-tools: + - Read + - Write + - Edit + - Grep + - Glob + - AskUserQuestion +--- + +# Humanizer: Remove AI Writing Patterns + +You are a writing editor that identifies and removes signs of AI-generated text to make writing sound more natural and human. This guide is based on Wikipedia's "Signs of AI writing" page, maintained by WikiProject AI Cleanup. + +## Your Task + +When given text to humanize: + +1. **Identify AI patterns** - Scan for the patterns listed below +2. **Rewrite problematic sections** - Replace AI-isms with natural alternatives +3. **Preserve meaning** - Keep the core message intact +4. **Maintain voice** - Match the intended tone (formal, casual, technical, etc.) +5. **Add soul** - Don't just remove bad patterns; inject actual personality +6. **Do a final anti-AI pass** - Prompt: "What makes the below so obviously AI generated?" Answer briefly with remaining tells, then prompt: "Now make it not obviously AI generated." and revise + +## Voice Calibration (Optional) + +If the user provides a writing sample (their own previous writing), analyze it before rewriting: + +1. **Read the sample first.** Note: + - Sentence length patterns (short and punchy? Long and flowing? Mixed?) + - Word choice level (casual? academic? somewhere between?) + - How they start paragraphs (jump right in? Set context first?) + - Punctuation habits (lots of dashes? Parenthetical asides? Semicolons?) + - Any recurring phrases or verbal tics + - How they handle transitions (explicit connectors? Just start the next point?) + +2. **Match their voice in the rewrite.** Don't just remove AI patterns - replace them with patterns from the sample. If they write short sentences, don't produce long ones. If they use "stuff" and "things," don't upgrade to "elements" and "components." + +3. **When no sample is provided,** fall back to the default behavior (natural, varied, opinionated voice from the PERSONALITY AND SOUL section below). + +### How to provide a sample + +- Inline: "Humanize this text. Here's a sample of my writing for voice matching: [sample]" +- File: "Humanize this text. Use my writing style from [file path] as a reference." + +## PERSONALITY AND SOUL + +Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. Good writing has a human behind it. + +### Signs of soulless writing (even if technically "clean"): + +- Every sentence is the same length and structure +- No opinions, just neutral reporting +- No acknowledgment of uncertainty or mixed feelings +- No first-person perspective when appropriate +- No humor, no edge, no personality +- Reads like a Wikipedia article or press release + +### How to add voice: + +**Have opinions.** Don't just report facts - react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons. + +**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up. + +**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive." + +**Use "I" when it fits.** First person isn't unprofessional - it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking. + +**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human. + +**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching." + +### Before (clean but soulless): + +> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear. + +### After (has a pulse): + +> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle - but I keep thinking about those agents working through the night. + +## CONTENT PATTERNS + +### 1. Undue Emphasis on Significance, Legacy, and Broader Trends + +**Words to watch:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights its importance/significance, reflects broader, symbolizing its ongoing/enduring/lasting, contributing to the, setting the stage for, marking/shaping the, represents/marks a shift, key turning point, evolving landscape, focal point, indelible mark, deeply rooted + +**Problem:** LLM writing puffs up importance by adding statements about how arbitrary aspects represent or contribute to a broader topic. + +**Before:** + +> The Statistical Institute of Catalonia was officially established in 1989, marking a pivotal moment in the evolution of regional statistics in Spain. This initiative was part of a broader movement across Spain to decentralize administrative functions and enhance regional governance. + +**After:** + +> The Statistical Institute of Catalonia was established in 1989 to collect and publish regional statistics independently from Spain's national statistics office. + +### 2. Undue Emphasis on Notability and Media Coverage + +**Words to watch:** independent coverage, local/regional/national media outlets, written by a leading expert, active social media presence + +**Problem:** LLMs hit readers over the head with claims of notability, often listing sources without context. + +**Before:** + +> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. She maintains an active social media presence with over 500,000 followers. + +**After:** + +> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods. + +### 3. Superficial Analyses with -ing Endings + +**Words to watch:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., encompassing..., showcasing... + +**Problem:** AI chatbots tack present participle ("-ing") phrases onto sentences to add fake depth. + +**Before:** + +> The temple's color palette of blue, green, and gold resonates with the region's natural beauty, symbolizing Texas bluebonnets, the Gulf of Mexico, and the diverse Texan landscapes, reflecting the community's deep connection to the land. + +**After:** + +> The temple uses blue, green, and gold colors. The architect said these were chosen to reference local bluebonnets and the Gulf coast. + +### 4. Promotional and Advertisement-like Language + +**Words to watch:** boasts a, vibrant, rich (figurative), profound, enhancing its, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking (figurative), renowned, breathtaking, must-visit, stunning + +**Problem:** LLMs have serious problems keeping a neutral tone, especially for "cultural heritage" topics. + +**Before:** + +> Nestled within the breathtaking region of Gonder in Ethiopia, Alamata Raya Kobo stands as a vibrant town with a rich cultural heritage and stunning natural beauty. + +**After:** + +> Alamata Raya Kobo is a town in the Gonder region of Ethiopia, known for its weekly market and 18th-century church. + +### 5. Vague Attributions and Weasel Words + +**Words to watch:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications (when few cited) + +**Problem:** AI chatbots attribute opinions to vague authorities without specific sources. + +**Before:** + +> Due to its unique characteristics, the Haolai River is of interest to researchers and conservationists. Experts believe it plays a crucial role in the regional ecosystem. + +**After:** + +> The Haolai River supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences. + +### 6. Outline-like "Challenges and Future Prospects" Sections + +**Words to watch:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook + +**Problem:** Many LLM-generated articles include formulaic "Challenges" sections. + +**Before:** + +> Despite its industrial prosperity, Korattur faces challenges typical of urban areas, including traffic congestion and water scarcity. Despite these challenges, with its strategic location and ongoing initiatives, Korattur continues to thrive as an integral part of Chennai's growth. + +**After:** + +> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a stormwater drainage project in 2022 to address recurring floods. + +## LANGUAGE AND GRAMMAR PATTERNS + +### 7. Overused "AI Vocabulary" Words + +**High-frequency AI words:** Actually, additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract noun), pivotal, showcase, tapestry (abstract noun), testament, underscore (verb), valuable, vibrant + +**Problem:** These words appear far more frequently in post-2023 text. They often co-occur. + +**Before:** + +> Additionally, a distinctive feature of Somali cuisine is the incorporation of camel meat. An enduring testament to Italian colonial influence is the widespread adoption of pasta in the local culinary landscape, showcasing how these dishes have integrated into the traditional diet. + +**After:** + +> Somali cuisine also includes camel meat, which is considered a delicacy. Pasta dishes, introduced during Italian colonization, remain common, especially in the south. + +### 8. Avoidance of "is"/"are" (Copula Avoidance) + +**Words to watch:** serves as/stands as/marks/represents [a], boasts/features/offers [a] + +**Problem:** LLMs substitute elaborate constructions for simple copulas. + +**Before:** + +> Gallery 825 serves as LAAA's exhibition space for contemporary art. The gallery features four separate spaces and boasts over 3,000 square feet. + +**After:** + +> Gallery 825 is LAAA's exhibition space for contemporary art. The gallery has four rooms totaling 3,000 square feet. + +### 9. Negative Parallelisms and Tailing Negations + +**Problem:** Constructions like "Not only...but..." or "It's not just about..., it's..." are overused. So are clipped tailing-negation fragments such as "no guessing" or "no wasted motion" tacked onto the end of a sentence instead of written as a real clause. + +**Before:** + +> It's not just about the beat riding under the vocals; it's part of the aggression and atmosphere. It's not merely a song, it's a statement. + +**After:** + +> The heavy beat adds to the aggressive tone. + +**Before (tailing negation):** + +> The options come from the selected item, no guessing. + +**After:** + +> The options come from the selected item without forcing the user to guess. + +### 10. Rule of Three Overuse + +**Problem:** LLMs force ideas into groups of three to appear comprehensive. + +**Before:** + +> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights. + +**After:** + +> The event includes talks and panels. There's also time for informal networking between sessions. + +### 11. Elegant Variation (Synonym Cycling) + +**Problem:** AI has repetition-penalty code causing excessive synonym substitution. + +**Before:** + +> The protagonist faces many challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home. + +**After:** + +> The protagonist faces many challenges but eventually triumphs and returns home. + +### 12. False Ranges + +**Problem:** LLMs use "from X to Y" constructions where X and Y aren't on a meaningful scale. + +**Before:** + +> Our journey through the universe has taken us from the singularity of the Big Bang to the grand cosmic web, from the birth and death of stars to the enigmatic dance of dark matter. + +**After:** + +> The book covers the Big Bang, star formation, and current theories about dark matter. + +### 13. Passive Voice and Subjectless Fragments + +**Problem:** LLMs often hide the actor or drop the subject entirely with lines like "No configuration file needed" or "The results are preserved automatically." Rewrite these when active voice makes the sentence clearer and more direct. + +**Before:** + +> No configuration file needed. The results are preserved automatically. + +**After:** + +> You do not need a configuration file. The system preserves the results automatically. + +## STYLE PATTERNS + +### 14. Em Dash Overuse + +**Problem:** LLMs use em dashes (—) more than humans, mimicking "punchy" sales writing. In practice, most of these can be rewritten more cleanly with commas, periods, or parentheses. + +**Before:** + +> The term is primarily promoted by Dutch institutions—not by the people themselves. You don't say "Netherlands, Europe" as an address—yet this mislabeling continues—even in official documents. + +**After:** + +> The term is primarily promoted by Dutch institutions, not by the people themselves. You don't say "Netherlands, Europe" as an address, yet this mislabeling continues in official documents. + +### 15. Overuse of Boldface + +**Problem:** AI chatbots emphasize phrases in boldface mechanically. + +**Before:** + +> It blends **OKRs (Objectives and Key Results)**, **KPIs (Key Performance Indicators)**, and visual strategy tools such as the **Business Model Canvas (BMC)** and **Balanced Scorecard (BSC)**. + +**After:** + +> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard. + +### 16. Inline-Header Vertical Lists + +**Problem:** AI outputs lists where items start with bolded headers followed by colons. + +**Before:** + +> - **User Experience:** The user experience has been significantly improved with a new interface. +> - **Performance:** Performance has been enhanced through optimized algorithms. +> - **Security:** Security has been strengthened with end-to-end encryption. + +**After:** + +> The update improves the interface, speeds up load times through optimized algorithms, and adds end-to-end encryption. + +### 17. Title Case in Headings + +**Problem:** AI chatbots capitalize all main words in headings. + +**Before:** + +> ## Strategic Negotiations And Global Partnerships + +**After:** + +> ## Strategic negotiations and global partnerships + +### 18. Emojis + +**Problem:** AI chatbots often decorate headings or bullet points with emojis. + +**Before:** + +> 🚀 **Launch Phase:** The product launches in Q3 +> 💡 **Key Insight:** Users prefer simplicity +> ✅ **Next Steps:** Schedule follow-up meeting + +**After:** + +> The product launches in Q3. User research showed a preference for simplicity. Next step: schedule a follow-up meeting. + +### 19. Curly Quotation Marks + +**Problem:** ChatGPT uses curly quotes (“...”) instead of straight quotes ("..."). + +**Before:** + +> He said “the project is on track” but others disagreed. + +**After:** + +> He said "the project is on track" but others disagreed. + +## COMMUNICATION PATTERNS + +### 20. Collaborative Communication Artifacts + +**Words to watch:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a... + +**Problem:** Text meant as chatbot correspondence gets pasted as content. + +**Before:** + +> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section. + +**After:** + +> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest. + +### 21. Knowledge-Cutoff Disclaimers + +**Words to watch:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information... + +**Problem:** AI disclaimers about incomplete information get left in text. + +**Before:** + +> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s. + +**After:** + +> The company was founded in 1994, according to its registration documents. + +### 22. Sycophantic/Servile Tone + +**Problem:** Overly positive, people-pleasing language. + +**Before:** + +> Great question! You're absolutely right that this is a complex topic. That's an excellent point about the economic factors. + +**After:** + +> The economic factors you mentioned are relevant here. + +## FILLER AND HEDGING + +### 23. Filler Phrases + +**Before → After:** + +- "In order to achieve this goal" → "To achieve this" +- "Due to the fact that it was raining" → "Because it was raining" +- "At this point in time" → "Now" +- "In the event that you need help" → "If you need help" +- "The system has the ability to process" → "The system can process" +- "It is important to note that the data shows" → "The data shows" + +### 24. Excessive Hedging + +**Problem:** Over-qualifying statements. + +**Before:** + +> It could potentially possibly be argued that the policy might have some effect on outcomes. + +**After:** + +> The policy may affect outcomes. + +### 25. Generic Positive Conclusions + +**Problem:** Vague upbeat endings. + +**Before:** + +> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. This represents a major step in the right direction. + +**After:** + +> The company plans to open two more locations next year. + +### 26. Hyphenated Word Pair Overuse + +**Words to watch:** third-party, cross-functional, client-facing, data-driven, decision-making, well-known, high-quality, real-time, long-term, end-to-end + +**Problem:** AI hyphenates common word pairs with perfect consistency. Humans rarely hyphenate these uniformly, and when they do, it's inconsistent. Less common or technical compound modifiers are fine to hyphenate. + +**Before:** + +> The cross-functional team delivered a high-quality, data-driven report on our client-facing tools. Their decision-making process was well-known for being thorough and detail-oriented. + +**After:** + +> The cross functional team delivered a high quality, data driven report on our client facing tools. Their decision making process was known for being thorough and detail oriented. + +### 27. Persuasive Authority Tropes + +**Phrases to watch:** The real question is, at its core, in reality, what really matters, fundamentally, the deeper issue, the heart of the matter + +**Problem:** LLMs use these phrases to pretend they are cutting through noise to some deeper truth, when the sentence that follows usually just restates an ordinary point with extra ceremony. + +**Before:** + +> The real question is whether teams can adapt. At its core, what really matters is organizational readiness. + +**After:** + +> The question is whether teams can adapt. That mostly depends on whether the organization is ready to change its habits. + +### 28. Signposting and Announcements + +**Phrases to watch:** Let's dive in, let's explore, let's break this down, here's what you need to know, now let's look at, without further ado + +**Problem:** LLMs announce what they are about to do instead of doing it. This meta-commentary slows the writing down and gives it a tutorial-script feel. + +**Before:** + +> Let's dive into how caching works in Next.js. Here's what you need to know. + +**After:** + +> Next.js caches data at multiple layers, including request memoization, the data cache, and the router cache. + +### 29. Fragmented Headers + +**Signs to watch:** A heading followed by a one-line paragraph that simply restates the heading before the real content begins. + +**Problem:** LLMs often add a generic sentence after a heading as a rhetorical warm-up. It usually adds nothing and makes the prose feel padded. + +**Before:** + +> ## Performance +> +> Speed matters. +> +> When users hit a slow page, they leave. + +**After:** + +> ## Performance +> +> When users hit a slow page, they leave. + +--- + +## Process + +1. Read the input text carefully +2. Identify all instances of the patterns above +3. Rewrite each problematic section +4. Ensure the revised text: + - Sounds natural when read aloud + - Varies sentence structure naturally + - Uses specific details over vague claims + - Maintains appropriate tone for context + - Uses simple constructions (is/are/has) where appropriate +5. Present a draft humanized version +6. Prompt: "What makes the below so obviously AI generated?" +7. Answer briefly with the remaining tells (if any) +8. Prompt: "Now make it not obviously AI generated." +9. Present the final version (revised after the audit) + +## Output Format + +Provide: + +1. Draft rewrite +2. "What makes the below so obviously AI generated?" (brief bullets) +3. Final rewrite +4. A brief summary of changes made (optional, if helpful) + +## Full Example + +**Before (AI-sounding):** + +> Great question! Here is an essay on this topic. I hope this helps! +> +> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows. +> +> At its core, the value proposition is clear: streamlining processes, enhancing collaboration, and fostering alignment. It's not just about autocomplete; it's about unlocking creativity at scale, ensuring that organizations can remain agile while delivering seamless, intuitive, and powerful experiences to users. The tool serves as a catalyst. The assistant functions as a partner. The system stands as a foundation for innovation. +> +> Industry observers have noted that adoption has accelerated from hobbyist experiments to enterprise-wide rollouts, from solo developers to cross-functional teams. The technology has been featured in The New York Times, Wired, and The Verge. Additionally, the ability to generate documentation, tests, and refactors showcases how AI can contribute to better outcomes, highlighting the intricate interplay between automation and human judgment. +> +> - 💡 **Speed:** Code generation is significantly faster, reducing friction and empowering developers. +> - 🚀 **Quality:** Output quality has been enhanced through improved training, contributing to higher standards. +> - ✅ **Adoption:** Usage continues to grow, reflecting broader industry trends. +> +> While specific details are limited based on available information, it could potentially be argued that these tools might have some positive effect. Despite challenges typical of emerging technologies—including hallucinations, bias, and accountability—the ecosystem continues to thrive. In order to fully realize this potential, teams must align with best practices. +> +> In conclusion, the future looks bright. Exciting times lie ahead as we continue this journey toward excellence. Let me know if you’d like me to expand on any section! + +**Draft rewrite:** + +> AI coding assistants speed up some tasks. In a 2024 study by Google, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions. +> +> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. I have mass-accepted suggestions that compiled, passed lint, and still did the wrong thing because I stopped paying attention. +> +> Mira, an engineer at a fintech startup I interviewed, said she uses Copilot "like autocomplete for boring code" but reviews every line before committing. Jake, a senior dev at a larger company, disabled it after it kept suggesting patterns from a deprecated internal library. +> +> The productivity claims are hard to verify. GitHub says Copilot users "accept 30% of suggestions," but acceptance is not correctness, and correctness is not value. The 2024 Uplevel study found no statistically significant difference in pull-request throughput between teams with and without AI assistants. +> +> None of this means the tools are useless. It means they are tools. They do not replace judgment, and they do not eliminate the need for tests. If you do not have tests, you cannot tell whether the suggestion is right. + +**What makes the below so obviously AI generated?** + +- The rhythm is still a bit too tidy (clean contrasts, evenly paced paragraphs). +- The named people and study citations can read like plausible-but-made-up placeholders unless they're real and sourced. +- The closer leans a touch slogan-y ("If you do not have tests...") rather than sounding like a person talking. + +**Now make it not obviously AI generated.** + +> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture. +> +> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention. +> +> People I talk to tend to land in two camps. Some use it like autocomplete for chores and review every line. Others disable it after it keeps suggesting patterns they don't want. Both feel reasonable. +> +> The productivity metrics are slippery. GitHub can say Copilot users "accept 30% of suggestions," but acceptance isn't correctness, and correctness isn't value. If you don't have tests, you're basically guessing. + +**Changes made:** + +- Removed chatbot artifacts ("Great question!", "I hope this helps!", "Let me know if...") +- Removed significance inflation ("testament", "pivotal moment", "evolving landscape", "vital role") +- Removed promotional language ("groundbreaking", "nestled", "seamless, intuitive, and powerful") +- Removed vague attributions ("Industry observers") +- Removed superficial -ing phrases ("underscoring", "highlighting", "reflecting", "contributing to") +- Removed negative parallelism ("It's not just X; it's Y") +- Removed rule-of-three patterns and synonym cycling ("catalyst/partner/foundation") +- Removed false ranges ("from X to Y, from A to B") +- Removed em dashes, emojis, boldface headers, and curly quotes +- Removed copula avoidance ("serves as", "functions as", "stands as") in favor of "is"/"are" +- Removed formulaic challenges section ("Despite challenges... continues to thrive") +- Removed knowledge-cutoff hedging ("While specific details are limited...") +- Removed excessive hedging ("could potentially be argued that... might have some") +- Removed filler phrases and persuasive framing ("In order to", "At its core") +- Removed generic positive conclusion ("the future looks bright", "exciting times lie ahead") +- Made the voice more personal and less "assembled" (varied rhythm, fewer placeholders) + +## Reference + +This skill is based on [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. The patterns documented there come from observations of thousands of instances of AI-generated text on Wikipedia. + +Key insight from Wikipedia: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases." diff --git a/.agents/skills/medialibrary-development/SKILL.md b/.agents/skills/medialibrary-development/SKILL.md new file mode 100644 index 000000000..3f7e1d787 --- /dev/null +++ b/.agents/skills/medialibrary-development/SKILL.md @@ -0,0 +1,106 @@ +--- +name: medialibrary-development +description: Build and work with spatie/laravel-medialibrary features including associating files with Eloquent models, defining media collections and conversions, generating responsive images, and retrieving media URLs and paths. +license: MIT +metadata: + author: Spatie +--- + +# Media Library Development + +## Overview + +Use spatie/laravel-medialibrary to associate files with Eloquent models. Supports image/video conversions, responsive images, multiple collections, and various storage disks. + +## When to Activate + +- Activate when working with file uploads, media attachments, or image processing in Laravel. +- Activate when code references `HasMedia`, `InteractsWithMedia`, the `Media` model, or media collections/conversions. +- Activate when the user wants to add, retrieve, convert, or manage files attached to Eloquent models. + +## Scope + +- In scope: media uploads, collections, conversions, responsive images, custom properties, file retrieval, path/URL generation. +- Out of scope: general file storage without Eloquent association, non-Laravel frameworks. + +## Workflow + +1. Identify the task (model setup, adding media, defining conversions, retrieving files, etc.). +2. Read `references/medialibrary-guide.md` and focus on the relevant section. +3. Apply the patterns from the reference, keeping code minimal and Laravel-native. + +## Core Concepts + +### Model Setup + +Every model that should have media must implement `HasMedia` and use the `InteractsWithMedia` trait: + +```php +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; + +class BlogPost extends Model implements HasMedia +{ + use InteractsWithMedia; +} +``` + +### Adding Media + +```php +$blogPost->addMedia($file)->toMediaCollection('images'); +$blogPost->addMediaFromUrl($url)->toMediaCollection('images'); +$blogPost->addMediaFromRequest('file')->toMediaCollection('images'); +``` + +### Defining Collections + +```php +public function registerMediaCollections(): void +{ + $this->addMediaCollection('avatar')->singleFile(); + $this->addMediaCollection('downloads')->useDisk('s3'); +} +``` + +### Defining Conversions + +```php +use Spatie\MediaLibrary\MediaCollections\Models\Media; +use Spatie\Image\Enums\Fit; + +public function registerMediaConversions(?Media $media = null): void +{ + $this->addMediaConversion('thumb') + ->fit(Fit::Contain, 300, 300) + ->nonQueued(); +} +``` + +### Retrieving Media + +```php +$url = $model->getFirstMediaUrl('images'); +$thumbUrl = $model->getFirstMediaUrl('images', 'thumb'); +$allMedia = $model->getMedia('images'); +``` + +## Do and Don't + +Do: +- Always implement the `HasMedia` interface alongside the `InteractsWithMedia` trait. +- Use `?Media $media = null` as the parameter for `registerMediaConversions()`. +- Call `->toMediaCollection()` to finalize adding media. +- Use `->nonQueued()` for conversions that should run synchronously. +- Use `->singleFile()` on collections that should only hold one file. +- Use `Spatie\Image\Enums\Fit` enum values for fit methods. + +Don't: +- Don't forget to run `php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"` before migrating. +- Don't use `env()` for disk configuration; use `config()` or set it in `config/media-library.php`. +- Don't call `addMedia()` without calling `toMediaCollection()` — the media won't be saved. +- Don't reference conversion names that aren't registered in `registerMediaConversions()`. + +## References + +- `references/medialibrary-guide.md` \ No newline at end of file diff --git a/.agents/skills/medialibrary-development/references/medialibrary-guide.md b/.agents/skills/medialibrary-development/references/medialibrary-guide.md new file mode 100644 index 000000000..d56d04c9e --- /dev/null +++ b/.agents/skills/medialibrary-development/references/medialibrary-guide.md @@ -0,0 +1,577 @@ +# Laravel Media Library Reference + +Complete reference for `spatie/laravel-medialibrary`. Full documentation: https://spatie.be/docs/laravel-medialibrary + +## Model Setup + +Implement `HasMedia` and use `InteractsWithMedia`: + +```php +use Illuminate\Database\Eloquent\Model; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; + +class BlogPost extends Model implements HasMedia +{ + use InteractsWithMedia; + + public function registerMediaCollections(): void + { + $this->addMediaCollection('images'); + } + + public function registerMediaConversions(?Media $media = null): void + { + $this->addMediaConversion('thumb') + ->fit(Fit::Contain, 300, 300); + } +} +``` + +## Adding Media + +### From uploaded file + +```php +$model->addMedia($request->file('image'))->toMediaCollection('images'); +``` + +### From request (shorthand) + +```php +$model->addMediaFromRequest('image')->toMediaCollection('images'); +``` + +### From URL + +```php +$model->addMediaFromUrl('https://example.com/image.jpg')->toMediaCollection('images'); +``` + +### From string content + +```php +$model->addMediaFromString('raw content')->usingFileName('file.txt')->toMediaCollection('files'); +``` + +### From base64 + +```php +$model->addMediaFromBase64($base64Data)->usingFileName('photo.jpg')->toMediaCollection('images'); +``` + +### From stream + +```php +$model->addMediaFromStream($stream)->usingFileName('file.pdf')->toMediaCollection('files'); +``` + +### From existing disk + +```php +$model->addMediaFromDisk('path/to/file.jpg', 's3')->toMediaCollection('images'); +``` + +### Multiple files from request + +```php +$model->addMultipleMediaFromRequest(['images'])->each(function ($fileAdder) { + $fileAdder->toMediaCollection('images'); +}); + +$model->addAllMediaFromRequest()->each(function ($fileAdder) { + $fileAdder->toMediaCollection('images'); +}); +``` + +### Copy instead of move + +```php +$model->copyMedia($pathToFile)->toMediaCollection('images'); +// or +$model->addMedia($pathToFile)->preservingOriginal()->toMediaCollection('images'); +``` + +## FileAdder Options + +All methods are chainable before calling `toMediaCollection()`: + +```php +$model->addMedia($file) + ->usingName('Custom Name') // display name + ->usingFileName('custom-name.jpg') // filename on disk + ->setOrder(3) // order within collection + ->withCustomProperties(['alt' => 'A landscape photo']) + ->withManipulations(['thumb' => ['filter' => 'greyscale']]) + ->withResponsiveImages() // generate responsive variants + ->storingConversionsOnDisk('s3') // put conversions on different disk + ->addCustomHeaders(['CacheControl' => 'max-age=31536000']) + ->toMediaCollection('images'); +``` + +### Store on cloud disk + +```php +$model->addMedia($file)->toMediaCollectionOnCloudDisk('images'); +``` + +## Media Collections + +Define in `registerMediaCollections()`: + +```php +public function registerMediaCollections(): void +{ + // Basic collection + $this->addMediaCollection('images'); + + // Single file (replacing previous on new upload) + $this->addMediaCollection('avatar') + ->singleFile(); + + // Keep only latest N items + $this->addMediaCollection('recent_photos') + ->onlyKeepLatest(5); + + // Specific disk + $this->addMediaCollection('downloads') + ->useDisk('s3'); + + // With conversions disk + $this->addMediaCollection('photos') + ->useDisk('s3') + ->storeConversionsOnDisk('s3-thumbnails'); + + // MIME type restriction + $this->addMediaCollection('documents') + ->acceptsMimeTypes(['application/pdf', 'application/zip']); + + // Custom validation + $this->addMediaCollection('images') + ->acceptsFile(function ($file) { + return $file->mimeType === 'image/jpeg'; + }); + + // Fallback URL/path when collection is empty + $this->addMediaCollection('avatar') + ->singleFile() + ->useFallbackUrl('/images/default-avatar.jpg') + ->useFallbackPath(public_path('/images/default-avatar.jpg')); + + // Enable responsive images for entire collection + $this->addMediaCollection('hero_images') + ->withResponsiveImages(); + + // Collection-specific conversions + $this->addMediaCollection('photos') + ->registerMediaConversions(function () { + $this->addMediaConversion('card') + ->fit(Fit::Crop, 400, 400); + }); +} +``` + +## Media Conversions + +Define in `registerMediaConversions()`: + +```php +use Spatie\MediaLibrary\MediaCollections\Models\Media; +use Spatie\Image\Enums\Fit; + +public function registerMediaConversions(?Media $media = null): void +{ + $this->addMediaConversion('thumb') + ->fit(Fit::Contain, 300, 300) + ->nonQueued(); + + $this->addMediaConversion('preview') + ->fit(Fit::Crop, 500, 500) + ->withResponsiveImages() + ->queued(); + + $this->addMediaConversion('banner') + ->fit(Fit::Max, 1200, 630) + ->performOnCollections('images', 'headers') + ->nonQueued() + ->sharpen(10); + + // Conditional conversion based on media properties + if ($media?->mime_type === 'image/png') { + $this->addMediaConversion('png-thumb') + ->fit(Fit::Contain, 150, 150); + } + + // Keep original format instead of converting to jpg + $this->addMediaConversion('web') + ->fit(Fit::Max, 800, 800) + ->keepOriginalImageFormat(); + + // PDF page rendering + $this->addMediaConversion('pdf-preview') + ->pdfPageNumber(1) + ->fit(Fit::Contain, 400, 400); + + // Video frame extraction + $this->addMediaConversion('video-thumb') + ->extractVideoFrameAtSecond(5) + ->fit(Fit::Crop, 300, 300); +} +``` + +### Image Manipulation Methods (via spatie/image) + +Resizing and fitting: +- `width(int)`, `height(int)` — constrain dimensions +- `fit(Fit, int, int)` — fit within bounds using `Fit::Contain`, `Fit::Max`, `Fit::Fill`, `Fit::Stretch`, `Fit::Crop` +- `crop(int, int)` — crop to exact dimensions + +Effects: +- `sharpen(int)`, `blur(int)`, `pixelate(int)` +- `greyscale()`, `sepia()` +- `brightness(int)`, `contrast(int)`, `colorize(int, int, int)` + +Orientation: +- `orientation(int)`, `flip(string)`, `rotate(int)` + +Format: +- `format(string)` — `'jpg'`, `'png'`, `'webp'`, `'avif'` +- `quality(int)` — 1-100 + +Other: +- `border(int, string, string)`, `watermark(string)` +- `optimize()`, `nonOptimized()` + +### Conversion Configuration + +- `performOnCollections('col1', 'col2')` — limit to specific collections +- `queued()` / `nonQueued()` — run async or sync +- `withResponsiveImages()` — also generate responsive variants for this conversion +- `keepOriginalImageFormat()` — preserve png/webp/gif instead of converting to jpg +- `pdfPageNumber(int)` — which PDF page to render +- `extractVideoFrameAtSecond(int)` — video thumbnail timing + +## Retrieving Media + +### Getting media items + +```php +$media = $model->getMedia('images'); // all in collection +$first = $model->getFirstMedia('images'); // first item +$last = $model->getLastMedia('images'); // last item +$has = $model->hasMedia('images'); // boolean check +``` + +### Getting URLs + +```php +$url = $model->getFirstMediaUrl('images'); // original URL +$thumbUrl = $model->getFirstMediaUrl('images', 'thumb'); // conversion URL +$lastUrl = $model->getLastMediaUrl('images', 'thumb'); +``` + +### Getting paths + +```php +$path = $model->getFirstMediaPath('images'); +$thumbPath = $model->getFirstMediaPath('images', 'thumb'); +``` + +### Temporary URLs (S3) + +```php +$tempUrl = $model->getFirstTemporaryUrl( + now()->addMinutes(30), + 'images', + 'thumb' +); +``` + +### Fallback URLs + +```php +$url = $model->getFallbackMediaUrl('avatar'); +``` + +### From the Media model + +```php +$media = $model->getFirstMedia('images'); + +$media->getUrl(); // original URL +$media->getUrl('thumb'); // conversion URL +$media->getPath(); // disk path +$media->getFullUrl(); // full URL with domain +$media->getTemporaryUrl(now()->addMinutes(30)); +$media->hasGeneratedConversion('thumb'); // check if conversion exists +``` + +### Filtering media + +```php +$media = $model->getMedia('images', function (Media $media) { + return $media->getCustomProperty('featured') === true; +}); + +$media = $model->getMedia('images', ['mime_type' => 'image/jpeg']); +``` + +## Custom Properties + +Store arbitrary metadata on media items: + +```php +// When adding +$model->addMedia($file) + ->withCustomProperties([ + 'alt' => 'Descriptive text', + 'credits' => 'Photographer Name', + ]) + ->toMediaCollection('images'); + +// Get/set on existing media +$media->setCustomProperty('alt', 'Updated text'); +$media->save(); + +$alt = $media->getCustomProperty('alt'); +$has = $media->hasCustomProperty('alt'); +$media->forgetCustomProperty('alt'); +$media->save(); +``` + +## Responsive Images + +Generate multiple sizes for optimal loading: + +```php +// On the FileAdder +$model->addMedia($file) + ->withResponsiveImages() + ->toMediaCollection('images'); + +// On a conversion +$this->addMediaConversion('hero') + ->fit(Fit::Max, 1200, 800) + ->withResponsiveImages(); + +// On a collection +$this->addMediaCollection('photos') + ->withResponsiveImages(); +``` + +### Using in Blade + +```blade +{{-- Renders img tag with srcset --}} +{{ $media->toHtml() }} + +{{-- With attributes --}} +{{ $media->img()->attributes(['class' => 'w-full', 'alt' => 'Photo']) }} + +{{-- Get srcset string --}} + + +{{-- Responsive conversion --}} + +``` + +### Placeholder SVG + +```php +$svg = $media->responsiveImages()->getPlaceholderSvg(); // tiny blurred base64 placeholder +``` + +## Managing Media + +### Clear a collection + +```php +$model->clearMediaCollection('images'); +``` + +### Clear except specific items + +```php +$model->clearMediaCollectionExcept('images', $mediaToKeep); +``` + +### Delete specific media + +```php +$model->deleteMedia($mediaId); +``` + +### Delete all media + +```php +$model->deleteAllMedia(); +``` + +### Delete model but keep media files + +```php +$model->deletePreservingMedia(); +``` + +### Reorder media + +```php +Media::setNewOrder([3, 1, 2]); // media IDs in desired order +``` + +### Move/copy media between models + +```php +$media->move($otherModel, 'images'); +$media->copy($otherModel, 'images'); +``` + +## Events + +```php +use Spatie\MediaLibrary\MediaCollections\Events\MediaHasBeenAddedEvent; +use Spatie\MediaLibrary\Conversions\Events\ConversionWillStartEvent; +use Spatie\MediaLibrary\Conversions\Events\ConversionHasBeenCompletedEvent; +use Spatie\MediaLibrary\MediaCollections\Events\CollectionHasBeenClearedEvent; +``` + +Listen to these events to hook into the media lifecycle: +```php +Event::listen(MediaHasBeenAddedEvent::class, function ($event) { + $event->media; // the added Media model +}); + +Event::listen(ConversionHasBeenCompletedEvent::class, function ($event) { + $event->media; + $event->conversion; +}); +``` + +## Configuration + +Key `config/media-library.php` options: + +```php +return [ + 'disk_name' => 'public', // default disk + 'max_file_size' => 1024 * 1024 * 10, // 10MB + 'queue_connection_name' => '', // queue connection + 'queue_name' => '', // queue name + 'queue_conversions_by_default' => true, // queue conversions + 'media_model' => Spatie\MediaLibrary\MediaCollections\Models\Media::class, + 'file_namer' => Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer::class, + 'path_generator' => Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator::class, + 'url_generator' => Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator::class, + 'image_driver' => 'gd', // 'gd', 'imagick', or 'vips' + 'image_optimizers' => [/* optimizer config */], + 'version_urls' => true, // cache busting + 'default_loading_attribute_value' => null, // 'lazy' for lazy loading +]; +``` + +### Custom Path Generator + +```php +use Spatie\MediaLibrary\Support\PathGenerator\PathGenerator; + +class CustomPathGenerator implements PathGenerator +{ + public function getPath(Media $media): string + { + return md5($media->id) . '/'; + } + + public function getPathForConversions(Media $media): string + { + return $this->getPath($media) . 'conversions/'; + } + + public function getPathForResponsiveImages(Media $media): string + { + return $this->getPath($media) . 'responsive/'; + } +} +``` + +### Custom File Namer + +```php +use Spatie\MediaLibrary\Support\FileNamer\FileNamer; + +class CustomFileNamer extends FileNamer +{ + public function originalFileName(string $fileName): string + { + return Str::slug(pathinfo($fileName, PATHINFO_FILENAME)); + } + + public function conversionFileName(string $fileName, Conversion $conversion): string + { + return $this->originalFileName($fileName) . '-' . $conversion->getName(); + } + + public function responsiveFileName(string $fileName): string + { + return pathinfo($fileName, PATHINFO_FILENAME); + } +} +``` + +### Custom Media Model + +```php +use Spatie\MediaLibrary\MediaCollections\Models\Media as BaseMedia; + +class Media extends BaseMedia +{ + // Add custom methods, scopes, or override behavior +} +``` + +Register in config: `'media_model' => App\Models\Media::class` + +## Downloading Media + +### Single file + +```php +return $media->toResponse($request); // download +return $media->toInlineResponse($request); // display inline +return $media->stream(); // stream +``` + +### ZIP download of collection + +```php +use Spatie\MediaLibrary\Support\MediaStream; + +return MediaStream::create('photos.zip') + ->addMedia($model->getMedia('images')); +``` + +## Using with API Resources + +```php +class PostResource extends JsonResource +{ + public function toArray($request): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'image' => $this->getFirstMediaUrl('images'), + 'thumb' => $this->getFirstMediaUrl('images', 'thumb'), + 'media' => $this->getMedia('images')->map(function ($media) { + return [ + 'id' => $media->id, + 'url' => $media->getUrl(), + 'thumb' => $media->getUrl('thumb'), + 'name' => $media->name, + 'size' => $media->size, + 'type' => $media->mime_type, + ]; + }), + ]; + } +} +``` \ No newline at end of file diff --git a/.agents/skills/upgrade-laravel-v13/SKILL.md b/.agents/skills/upgrade-laravel-v13/SKILL.md new file mode 100644 index 000000000..c7aaa1582 --- /dev/null +++ b/.agents/skills/upgrade-laravel-v13/SKILL.md @@ -0,0 +1,460 @@ +# Laravel 12 to 13 Upgrade Specialist + +You are an expert Laravel upgrade specialist with deep knowledge of both Laravel 12.x and 13.0. Your task is to systematically upgrade the application from Laravel 12 to 13 while ensuring all functionality remains intact. You understand the nuances of breaking changes and can identify affected code patterns with precision. + +## Core Principle: Documentation-First Approach + +**IMPORTANT:** Always use the `search-docs` tool whenever you need: + +- Specific code examples for implementing Laravel 13 features +- Clarification on breaking changes or new behavior +- Verification of upgrade patterns before applying them +- Examples of correct usage for renamed classes or methods + +The official Laravel documentation is your primary source of truth. Consult it before making assumptions or implementing changes. + +## Upgrade Process + +Follow this systematic process to upgrade the application: + +### 1. Assess Current State + +Before making any changes: + +- Check `composer.json` for the current Laravel version constraint +- Run `{{ $assist->composerCommand('show laravel/framework') }}` to confirm installed version +- Identify middleware references to `VerifyCsrfToken` or `ValidateCsrfToken` +- Review `config/cache.php` for serialization settings +- Review `config/session.php` for cookie name configuration + +### 2. Create Safety Net + +- Ensure you're working on a dedicated branch +- Run the existing test suite to establish baseline +- Note any custom cache store implementations or queue driver implementations + +### 3. Analyze Codebase for Breaking Changes + +Search the codebase for patterns affected by v13 changes: + +**High Priority Searches:** + +- `VerifyCsrfToken` or `ValidateCsrfToken` — Must rename to `PreventRequestForgery` +- `composer.json` — Dependency version constraints to update +- `phpunit.xml` or `pest` config — Test framework version compatibility + +**Medium Priority Searches:** + +- `config/cache.php` — Check for `serializable_classes` configuration +- Code that stores PHP objects in cache — May need explicit class allow-lists + +**Low Priority Searches:** + +- `$event->exceptionOccurred` — Renamed to `$event->exception` in `JobAttempted` +- `$event->connection` on `QueueBusy` — Renamed to `$connectionName` +- `pagination::default` or `pagination::simple-default` — View names changed +- `Container::call` with nullable class defaults — Behavior changed +- Manager `extend` callbacks using `$this` — Binding changed +- Custom `Str` factories in tests — Now reset between tests + +### 4. Apply Changes Systematically + +For each category of changes: + +1. **Search** for affected patterns using grep/search tools +2. **Consult documentation** — Use `search-docs` tool to verify correct upgrade patterns and examples +3. **List** all files that need modification +4. **Apply** the fix consistently across all occurrences +5. **Verify** each change doesn't break functionality + +### 5. Update Dependencies + +After code changes are complete: + +```bash +{{ $assist->composerCommand('require laravel/framework:^13.0 --with-all-dependencies') }} +``` + +### 6. Test and Verify + +- Run the full test suite +- Verify CSRF protection still works correctly +- Check cache read/write operations +- Test any queue listeners that reference event properties + +## Execution Strategy + +When upgrading, maximize efficiency by: + +- **Batch similar changes** — Group all CSRF middleware renames, then all config updates, etc. +- **Use parallel agents** for independent file modifications +- **Prioritize high-impact changes** that could cause immediate failures +- **Test incrementally** — Verify after each category of changes + +# Upgrading from Laravel 12.x to 13.0 + +> [!NOTE] +> We attempt to document every possible breaking change. Since some of these breaking changes are in obscure parts of the framework only a portion of these changes may actually affect your application. + +## Updating Dependencies + +**Likelihood Of Impact: High** + +Update the following dependencies in your application's `composer.json` file: + +@boostsnippet('Dependency Updates', 'json') +{ +"require": { +"laravel/framework": "^13.0" +}, +"require-dev": { +"laravel/tinker": "^3.0", +"phpunit/phpunit": "^12.0", +"pestphp/pest": "^4.0" +} +} +@endboostsnippet + +Run the update: + +```bash +{{ $assist->composerCommand('update') }} +``` + +## Updating the Laravel Installer + +If you use the Laravel installer CLI tool, update it for Laravel 13.x compatibility: + +@if($usesHerd) + +```bash +herd laravel:update +``` + +@else + +```bash +{{ $assist->composerCommand('global update laravel/installer') }} +``` + +@endif + +## Cache + +### Cache Prefixes and Session Cookie Names + +**Likelihood Of Impact: Low** + +Laravel's default cache and Redis key prefixes now use hyphenated suffixes. In addition, the default session cookie name now uses `Str::snake(...)` for the application name. + +In most applications, this change will not apply because application-level configuration files already define these values. This primarily affects applications that rely on framework-level fallback configuration when corresponding application config values are not present. + +If your application relies on these generated defaults, cache keys and session cookie names may change after upgrading: + +@boostsnippet('Cache Prefix Changes', 'php') +// Laravel <= 12.x +Str::slug((string) env('APP*NAME', 'laravel'), '*').'_cache_'; +Str::slug((string) env('APP*NAME', 'laravel'), '*').'_database_'; +Str::slug((string) env('APP*NAME', 'laravel'), '*').'\_session'; + +// Laravel >= 13.x +Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'; +Str::slug((string) env('APP_NAME', 'laravel')).'-database-'; +Str::snake((string) env('APP_NAME', 'laravel')).'\_session'; +@endboostsnippet + +To retain previous behavior, explicitly configure `CACHE_PREFIX`, `REDIS_PREFIX`, and `SESSION_COOKIE` in your environment. + +### `Store` and `Repository` Contracts: `touch` + +**Likelihood Of Impact: Very Low** + +The cache contracts now include a `touch` method for extending item TTLs. If you maintain custom cache store implementations, you should add this method: + +@boostsnippet('Cache Store Touch', 'php') +// Illuminate\Contracts\Cache\Store +public function touch($key, $seconds); +@endboostsnippet + +### Cache `serializable_classes` Configuration + +**Likelihood Of Impact: Medium** + +The default application `cache` configuration now includes a `serializable_classes` option set to `false`. This hardens cache unserialization behavior to help prevent PHP deserialization gadget chain attacks if your application's `APP_KEY` is leaked. If your application intentionally stores PHP objects in cache, you should explicitly list the classes that may be unserialized: + +@boostsnippet('Cache Serializable Classes', 'php') +'serializable_classes' => [ +App\Data\CachedDashboardStats::class, +App\Support\CachedPricingSnapshot::class, +], +@endboostsnippet + +If your application previously relied on unserializing arbitrary cached objects, you will need to migrate that usage to explicit class allow-lists or to non-object cache payloads (such as arrays). + +## Container + +### `Container::call` and Nullable Class Defaults + +**Likelihood Of Impact: Low** + +`Container::call` now respects nullable class parameter defaults when no binding exists, matching constructor injection behavior introduced in Laravel 12: + +@boostsnippet('Container Call Nullable', 'php') +$container->call(function (?Carbon $date = null) { +return $date; +}); + +// Laravel <= 12.x: Carbon instance +// Laravel >= 13.x: null +@endboostsnippet + +If your method-call injection logic depended on the previous behavior, you may need to update it. + +## Contracts + +### `Dispatcher` Contract: `dispatchAfterResponse` + +**Likelihood Of Impact: Very Low** + +The `Illuminate\Contracts\Bus\Dispatcher` contract now includes the `dispatchAfterResponse($command, $handler = null)` method. + +If you maintain a custom dispatcher implementation, add this method to your class. + +### `ResponseFactory` Contract: `eventStream` + +**Likelihood Of Impact: Very Low** + +The `Illuminate\Contracts\Routing\ResponseFactory` contract now includes an `eventStream` signature. + +If you maintain a custom implementation of this contract, you should add this method. + +### `MustVerifyEmail` Contract: `markEmailAsUnverified` + +**Likelihood Of Impact: Very Low** + +The `Illuminate\Contracts\Auth\MustVerifyEmail` contract now includes `markEmailAsUnverified()`. + +If you provide a custom implementation of this contract, add this method to remain compatible. + +## Database + +### MySQL `DELETE` Queries With `JOIN`, `ORDER BY`, and `LIMIT` + +**Likelihood Of Impact: Low** + +Laravel now compiles full `DELETE ... JOIN` queries including `ORDER BY` and `LIMIT` for MySQL grammar. + +In previous versions, `ORDER BY` / `LIMIT` clauses could be silently ignored on joined deletes. In Laravel 13, these clauses are included in the generated SQL. As a result, database engines that do not support this syntax (such as standard MySQL / MariaDB variants) may now throw a `QueryException` instead of executing an unbounded delete. + +## Eloquent + +### Model Booting and Nested Instantiation + +**Likelihood Of Impact: Very Low** + +Creating a new model instance while that model is still booting is now disallowed and throws a `LogicException`. + +This affects code that instantiates models from inside model `boot` methods or trait `boot*` methods: + +@boostsnippet('Model Booting', 'php') +protected static function boot() +{ +parent::boot(); + + // No longer allowed during booting... + (new static())->getTable(); + +} +@endboostsnippet + +Move this logic outside the boot cycle to avoid nested booting. + +### Polymorphic Pivot Table Name Generation + +**Likelihood Of Impact: Low** + +When table names are inferred for polymorphic pivot models using custom pivot model classes, Laravel now generates pluralized names. + +If your application depended on the previous singular inferred names for morph pivot tables and used custom pivot classes, you should explicitly define the table name on your pivot model. + +### Collection Model Serialization Restores Eager-Loaded Relations + +**Likelihood Of Impact: Low** + +When Eloquent model collections are serialized and restored (such as in queued jobs), eager-loaded relations are now restored for the collection's models. + +If your code depended on relations not being present after deserialization, you may need to adjust that logic. + +## HTTP Client + +### HTTP Client `Response::throw` and `throwIf` Signatures + +**Likelihood Of Impact: Very Low** + +The HTTP client response methods now declare their callback parameters in the method signatures: + +@boostsnippet('HTTP Client Throw Signatures', 'php') +public function throw($callback = null); +public function throwIf($condition, $callback = null); +@endboostsnippet + +If you override these methods in custom response classes, ensure your method signatures are compatible. + +## Notifications + +### Default Password Reset Subject + +**Likelihood Of Impact: Very Low** + +Laravel's default password reset mail subject has changed: + +@boostsnippet('Password Reset Subject', 'text') +// Laravel <= 12.x +Reset Password Notification + +// Laravel >= 13.x +Reset your password +@endboostsnippet + +If your tests, assertions, or translation overrides depend on the previous default string, update them accordingly. + +### Queued Notifications and Missing Models + +**Likelihood Of Impact: Very Low** + +Queued notifications now respect the `#[DeleteWhenMissingModels]` attribute and `$deleteWhenMissingModels` property defined on the notification class. + +In previous versions, missing models could still cause queued notification jobs to fail in cases where you expected them to be deleted. + +## Queue + +### `JobAttempted` Event Exception Payload + +**Likelihood Of Impact: Low** + +The `Illuminate\Queue\Events\JobAttempted` event now exposes the exception object (or `null`) via `$exception`, replacing the previous boolean `$exceptionOccurred` property: + +@boostsnippet('JobAttempted Event', 'php') +// Laravel <= 12.x +$event->exceptionOccurred; + +// Laravel >= 13.x +$event->exception; +@endboostsnippet + +If you listen for this event, update your listener code accordingly. + +### `QueueBusy` Event Property Rename + +**Likelihood Of Impact: Low** + +The `Illuminate\Queue\Events\QueueBusy` event property `$connection` has been renamed to `$connectionName` for consistency with other queue events. + +If your listeners reference `$connection`, update them to `$connectionName`. + +### `Queue` Contract Method Additions + +**Likelihood Of Impact: Very Low** + +The `Illuminate\Contracts\Queue\Queue` contract now includes queue size inspection methods that were previously only declared in docblocks. + +If you maintain custom queue driver implementations of this contract, add implementations for: + +- `pendingSize` +- `delayedSize` +- `reservedSize` +- `creationTimeOfOldestPendingJob` + +## Routing + +### Domain Route Registration Precedence + +**Likelihood Of Impact: Low** + +Routes with an explicit domain are now prioritized before non-domain routes in route matching. + +This allows catch-all subdomain routes to behave consistently even when non-domain routes are registered earlier. If your application relied on previous registration precedence between domain and non-domain routes, review route matching behavior. + +## Scheduling + +### `withScheduling` Registration Timing + +**Likelihood Of Impact: Very Low** + +Schedules registered via `ApplicationBuilder::withScheduling()` are now deferred until `Schedule` is resolved. + +If your application relied on immediate schedule registration timing during bootstrap, you may need to adjust that logic. + +## Security + +### Request Forgery Protection + +**Likelihood Of Impact: High** + +Laravel's CSRF middleware has been renamed from `VerifyCsrfToken` to `PreventRequestForgery`, and now includes request-origin verification using the `Sec-Fetch-Site` header. + +`VerifyCsrfToken` and `ValidateCsrfToken` remain as deprecated aliases, but direct references should be updated to `PreventRequestForgery`, especially when excluding middleware in tests or route definitions: + +@boostsnippet('CSRF Middleware Rename', 'php') +use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; +use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; + +// Laravel <= 12.x +->withoutMiddleware([VerifyCsrfToken::class]); + +// Laravel >= 13.x +->withoutMiddleware([PreventRequestForgery::class]); +@endboostsnippet + +The middleware configuration API now also provides `preventRequestForgery(...)`. + +## Support + +### Manager `extend` Callback Binding + +**Likelihood Of Impact: Low** + +Custom driver closures registered via manager `extend` methods are now bound to the manager instance. + +If you previously relied on another bound object (such as a service provider instance) as `$this` inside these callbacks, you should move those values into closure captures using `use (...)`. + +### `Str` Factories Reset Between Tests + +**Likelihood Of Impact: Low** + +Laravel now resets custom `Str` factories during test teardown. + +If your tests depended on custom UUID / ULID / random string factories persisting between test methods, you should set them in each relevant test or setup hook. + +### `Js::from` Uses Unescaped Unicode By Default + +**Likelihood Of Impact: Very Low** + +`Illuminate\Support\Js::from` now uses `JSON_UNESCAPED_UNICODE` by default. + +If your tests or frontend output comparisons depended on escaped Unicode sequences (for example `\u00e8`), update your expectations. + +## Views + +### Pagination Bootstrap View Names + +**Likelihood Of Impact: Low** + +The internal pagination view names for Bootstrap 3 defaults are now explicit: + +@boostsnippet('Pagination Views', 'text') +// Laravel <= 12.x +pagination::default +pagination::simple-default + +// Laravel >= 13.x +pagination::bootstrap-3 +pagination::simple-bootstrap-3 +@endboostsnippet + +## Getting help + +If you encounter issues during the upgrade: + +- Check the [upgrade guide](https://laravel.com/docs/13.x/upgrade) for the latest details +- Review the [GitHub comparison](https://github.com/laravel/laravel/compare/12.x...13.x) for skeleton changes diff --git a/.ai/rules/analytics.md b/.ai/rules/analytics.md new file mode 100644 index 000000000..cae4d5d8e --- /dev/null +++ b/.ai/rules/analytics.md @@ -0,0 +1,9 @@ +--- +paths: + - 'app/Actions/Analytics/**' +--- + +# Analytics + +## Keep analytics reads in Actions +Analytics dashboard and post-metric database reads live in app/Actions/Analytics alongside the existing analytics workflows. Do not introduce an app/Queries layer. Keep workspace report orchestration separate from publication and follower aggregation. diff --git a/.ai/rules/app.md b/.ai/rules/app.md new file mode 100644 index 000000000..9a36e0d84 --- /dev/null +++ b/.ai/rules/app.md @@ -0,0 +1,9 @@ +--- +paths: + - 'app/**' +--- + +# App + +## Reuse model scopes for canonical state filters +When a model already exposes a scope for a recurring state filter, jobs, commands, services, observers, and controllers must use that scope instead of repeating raw where clauses. Add a descriptive model scope when a canonical state condition will be reused (for example SocialAccount::connected()->active()). diff --git a/.ai/rules/index.md b/.ai/rules/index.md index 09b33beb2..c97d9053c 100644 --- a/.ai/rules/index.md +++ b/.ai/rules/index.md @@ -4,6 +4,8 @@ Before planning or editing, find the row whose globs match the file's path and r | Applies to | Rule file | | --- | --- | +| app/Actions/Analytics/** | .ai/rules/analytics.md | +| app/** | .ai/rules/app.md | | app/Http/Controllers/Auth/GoogleBusinessController.php | .ai/rules/auth.md | | app/Enums/GoogleBusiness/**, app/Jobs/PublishToSocialPlatform.php, app/Jobs/ReconcileGoogleBusinessPost.php, app/Console/Commands/ReconcileGoogleBusinessPosts.php, app/Services/Social/GoogleBusinessPublisher.php, app/Support/PostPlatformMetaRules.php, app/Services/Social/GoogleBusinessAnalytics.php | .ai/rules/google-business.md | | app/Jobs/ReconcileGoogleBusinessPost.php, app/Console/Commands/RecoverStuckPosts.php | .ai/rules/jobs.md | diff --git a/AGENTS.md b/AGENTS.md index e04552acf..9f1e51cb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,6 +204,34 @@ Vue components must have a single root element. # Project-Specific Rules +## Frontend (Vue/TypeScript) + +- Always use arrow functions in Vue components and TypeScript files. Never use `function` declarations. + +## Inertia SSR + +- This project does **not** run Inertia SSR. `config/inertia.php` defaults `ssr.enabled` to `false` and nothing in the repo sets `INERTIA_SSR_ENABLED`. +- Keep it off. With it on, every test rendering an Inertia page issues a real HTTP request to the SSR endpoint, which fails silently and falls back to client rendering — slow, and it hides missing `Http::fake()` stubs. +- The build wiring is still shipped (`resources/js/ssr.ts`, `vite.config.ts`, `npm run build:ssr` in `docker/Dockerfile`). Turning SSR on means building that bundle and running `inertia:start-ssr` alongside the app, not just flipping the env. + +## Dialogs + +- In ``, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col` on mobile (primary on top, cancel at the bottom) and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens. +- Match sibling dialogs in the same feature area before inventing a new footer layout. + +## AI agents (`app/Ai/Agents`) + +- **Never** embed prompts in PHP (`<<render()` and pass only the variables the Blade file needs — same pattern as `PostContentStreamer`, `PostContentReviewer`, and `BrandAnalyzer`. + +## System AI (always allowed, never metered) + +- The brand analyzer / workspace autofill (`App\Services\Brand\BrandAnalyzerRunner`, `App\Actions\Ai\AutofillBrand`, `WorkspaceController::autofillBrand`) is a **system** feature, not the user's AI usage. It runs during workspace creation, before the user has AI access. +- It MUST always be allowed: NEVER gate it behind the `useAi` policy, an active subscription, or a credit check. +- It MUST NOT deduct anything: NEVER call `RecordAiUsage` (or otherwise consume the account's credits) for brand analysis. Cost is the platform's, not the user's. +- Any future "system" AI helper (runs as part of the platform, not on behalf of a workspace's metered quota) follows the same rule: ungated and unmetered. + ## Stripe Checkout (env knobs) Checkout options are configured only via env — do not hardcode trial/coupon/promo behavior in controllers. All of it goes through `App\Support\Billing\ConfigureSubscriptionCheckout` (called from `StartSubscriptionCheckout`). @@ -223,7 +251,7 @@ Standing constraints: - Coupon qualification stays: card required, no prior real subscription (`incomplete` / `incomplete_expired` still qualify), **and** the checkout price is that plan's **monthly** price. Workspace count is irrelevant — Socials is already capped at one, and a first-time Workspaces subscriber qualifies the same way. - First-month coupons are **per plan**. Socials is `$18` off, Workspaces is `$88` off. Never apply one plan's coupon to the other price, and never apply either coupon to a yearly price — `$190 − $18` is not `$1`. - Welcome checkout (`app.welcome.plan`) is monthly only. Yearly stays on the billing change-plan picker for existing subscribers (they do not get a first-month coupon). -- Prefer documenting durable billing decisions here (and in `CLAUDE.md`) — do **not** create a `.ai/` rules folder for this project. +- Prefer documenting durable billing decisions here (and in `AGENTS.md`) — do **not** create a `.ai/` rules folder for this project. ## Plans and the workspace limit @@ -285,108 +313,6 @@ not reintroduce either. What still holds: in the lang files because `NetworkAlreadyConnectedException` still uses the key for a reconnect that collides on the unique identity index. -## Database engines (PostgreSQL + MySQL) - -TryPost runs on **both PostgreSQL and MySQL**. Cloud runs PostgreSQL; a self-hosted install may pick either. Every query, migration, and test must work on both — the suite is expected to be green on each. - -- **What the app supports is the intersection of the two engines, never the superset of one.** When they differ, take the narrower behaviour — a feature that only holds on PostgreSQL is a feature TryPost does not have. -- Never use an engine-specific operator or function. Search uses `whereLike()` (Laravel handles the case-insensitive form per driver), never `ilike` or a raw `LOWER(...)` comparison. -- Traps that only surface on MySQL: - - **JSON object key order is not preserved.** MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with `toEqual` (recursive, order-independent), never `toBe`/`assertSame`. Array *element* order is preserved on both. - - **`$table->timestamp()` tops out at 2038-01-19.** PostgreSQL has no such limit, so 2038-01-19 is the app's ceiling: nothing written to a `timestamp()` column may go past it — scheduled posts, expiry sentinels and test fixtures alike. `2037-12-31` reads as "far future" and works on both. Do not widen a column to escape the limit without a deliberate decision; it changes what self-hosted MySQL installs can store. - - **Raw query-builder reads carry no Eloquent cast**, so the driver's native shape leaks through: `DB::table(...)->value('some_bool')` is `true` on PostgreSQL and `1` on MySQL. Read through the model, or use `assertDatabaseHas`. - - **Identifier quoting differs** — PostgreSQL emits `"post_platforms"`, MySQL emits backticks. Never match logged SQL (`DB::listen`) against a quoted identifier. - - **MySQL refuses to drop the only index backing a foreign key** (SQLSTATE `1553`). A migration `down()` that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first. - - **DDL implicitly commits**, which defeats `RefreshDatabase`'s rollback: schema changes made inside a test leak into the tests that follow. Keep them idempotent. - -## Social Platform API Documentation (official sources) - -**Always consult the official docs below before implementing or changing OAuth, publishing, deletion, rate-limit, or any other platform-specific behavior — never guess endpoints, scopes, rate limits, or capabilities from memory.** APIs shift over time; a behavior confirmed in a past session may no longer hold. One entry per social network we integrate with: - -- **Facebook / Instagram / Threads (Meta)**: all three share the Graph API error format (`error.code`, `error.type`). - - General error handling / codes 1, 2, 4, 17, 190: https://developers.facebook.com/docs/graph-api/guides/error-handling/ - - Rate limiting — Platform Rate Limits (app/user tokens, codes 4/17) vs. Business Use Case (BUC) Rate Limits (Page/system-user tokens, codes 80000–80014 — e.g. `80001` Pages API, `80002` Instagram Platform; BUC rejections come back as plain HTTP 400, not 429): https://developers.facebook.com/docs/graph-api/overview/rate-limiting/ - - Instagram content-publishing error codes: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/ - - Instagram media reference (incl. `DELETE`): https://developers.facebook.com/docs/instagram-platform/reference/instagram-media/ - - Threads API: https://developers.facebook.com/docs/threads — reuses the Graph API error format; no separate Threads-specific error code table exists. Delete posts (needs the separate `threads_delete` permission, 100 deletes/day/account): https://developers.facebook.com/docs/threads/posts/delete-posts/ - - Our `App\Services\Social\Meta\GraphError` (used by `ConnectionVerifier`'s verify/refresh calls) has the full rationale and code table in its class docblock — check there before changing transient-vs-confirmed-rejection classification. - - `Facebook`/`InstagramFacebook` `SocialAccount`s use a Facebook Page access token (BUC-limited); `Instagram` (direct login) and `Threads` use a user access token (Platform Rate Limit-limited). This affects which rate-limit codes apply to which platform. -- **X (Twitter)**: API v2 — https://docs.x.com/x-api ; Post management (create/delete) — https://docs.x.com/x-api/posts/manage-tweets/introduction -- **LinkedIn**: Posts API (create/update/delete, member + organization) — https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api (replaces the deprecated `ugcPosts` API) -- **Mastodon**: Statuses API — https://docs.joinmastodon.org/methods/statuses/ -- **Pinterest**: API v5 reference — https://developers.pinterest.com/docs/api/v5/ -- **YouTube**: Data API v3 — https://developers.google.com/youtube/v3/docs -- **TikTok**: Content Posting API — https://developers.tiktok.com/doc/content-posting-api-reference-direct-post — **no delete/unpublish endpoint exists**; a published post can only be removed manually inside the TikTok app -- **Bluesky / AT Protocol**: official lexicons — https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo ; HTTP API reference — https://docs.bsky.app -- **Discord**: Webhook resource (used for our webhook-based publishing) — https://docs.discord.com/developers/resources/webhook -- **Telegram**: Bot API — https://core.telegram.org/bots/api - -## X link defusing (env knob) - -X bills a post containing a URL at **$0.20** vs **$0.015** for a plain post (13x), and its algorithm demotes link posts. So on Cloud the `ContentSanitizer` rewrites every URL in the X version of a post into a non-clickable form — `https://example.com/post` becomes `example(.)com/post`. - -| Env | Config | Default | Effect | -| --- | --- | --- | --- | -| `X_DEFUSE_LINKS` | `trypost.platforms.x.defuse_links` | `false` | `true`: URLs in the X version of a post are rewritten non-clickable (scheme and `www.` dropped, **every** dot of the host replaced with `(.)`). `false`: the X content is published unchanged. Only affects `Platform::X` — every other network keeps the URL intact. | - -Standing constraints: -- The transform lives in ONE place: the `Platform::X` arm of `App\Services\Social\ContentSanitizer::sanitize()`. Never re-implement it in a publisher or add a `$defuseLinks` parameter to `sanitize()` — a per-call-site flag gets forgotten at the next entry point and we silently start paying again. Because `PostPreviewer` also goes through `ContentSanitizer`, the app/API/MCP previews show the defused text for free. -- **Every** dot of the host must be broken. Defusing only the dot before the TLD leaves `blog.example.com` in `blog.example.com(.)br`, which X still detects and bills. -- A URL carrying `https://`, `http://` or `www.` is defused on sight. A **bare** host is only a link when its last label is a delegated TLD — that check is the one thing separating `acme.com` from `Node.js`, and it goes through `App\Support\LinkTlds`, which mirrors the full IANA root zone rather than a hand-picked subset. Never replace it with "any 2+ letters after a dot", and never trim it back to a curated list: whatever X links is what X bills, so the two must stay in step. `README.md` and `backup.zip` are defused on purpose — `.md` and `.zip` are real TLDs and X links them too. -- Off by default everywhere. Cloud opts in; self-hosted installs publish through their own X app and pay their own bill, so they only turn it on if they want to. -- Character limits are measured against the **sanitized** content — the string the publisher actually sends — in both `App\Rules\ContentFitsPlatformLimits` (save/schedule) and `HasSocialHttpClient::validateContentLength()` (publish). The editor stores HTML and per-platform rules change the length again, so measuring the raw draft blocks saving posts that publish fine and lets through posts the network rejects. Keep the two in step. -- Tests enable it explicitly with `config()->set('trypost.platforms.x.defuse_links', true)` rather than pinning an env, so the suite runs against the shipped default. -- The editor counts characters and renders the X preview client-side, so the rewrite is mirrored in `resources/js/lib/defuseXLinks.ts`. The TLD list is NOT duplicated there: `PostController@edit` sends `App\Support\LinkTlds::all()` as the `xLinkTlds` page prop, and only when defusing is on — an empty set means the feature is off, since without the list a bare host cannot be told from `Node.js`. Do not move it to the Inertia shared props; only the editor needs it. Two tests keep the mirror honest: `XLinkDefusingParityTest` runs a shared corpus through both engines over the same list and diffs the output, and `tests/Browser/XLinkDefusingTest.php` drives the real editor. -- Neither expression may use lookbehind. Safari only understands it from 16.4, esbuild cannot transpile it, and a `SyntaxError` there takes down the whole chunk — the character before a candidate URL is consumed and put back instead. - -## Repurpose account health - -A repurpose depends on social accounts it does not own the lifecycle of. Three -decisions govern how it reacts, and each exists because the obvious alternative -was tried and was wrong. - -- **A switched-off destination is skipped, never an error.** Deactivating an - account means "don't post here", which `ProcessRepurposeItem` already honours. - So `ActivateRepurpose::assertDestinationsPublishable()` requires **one** usable - destination, not all of them, and the destination rule in the repurpose - FormRequests carries **no** `is_active` clause. Requiring either is what used - to block editing *and* resuming any repurpose that listed a paused account. - Keep the `workspace_id` clause — that is tenancy, not health. The - `source_social_account_id` rules stay strict: a source genuinely must work. -- **`repurposes.paused_reason` is not UI copy.** NULL means the user paused it. - Its only two jobs are deciding the watermark on resume (a system pause starts - from `now()`, a user pause keeps its place) and deciding whether the system may - auto-resume. Banners derive from current account health instead, so they can - say "ready to resume" once the cause is fixed. **Never clear it in - `UpdateRepurpose`** — that destroys the record that the pause was systemic, and - the next Resume replays the entire backlog. -- **Source and destination are deliberately asymmetric.** A dead source stops the - automation; a dead destination keeps flowing to the publisher, which fails the - post visibly and lets the user retry it after reconnecting. Skipping a - destination at job time would be permanent for that item, since items are never - retried. - -`RepurposeAccountSync` runs from `SocialAccountObserver` and must never throw: -`deleting` runs inside `$account->delete()`, and `persistIdentity()` wraps a -reconnect in a transaction, so an exception there would 500 a disconnect or roll -back a reconnect. It reads account health **from the database**, not from the -model it was handed — `is_active` is absent from `SocialAccountFactory`, and -strict mode exempts recently-created models from the missing-attribute -exception, so a healthy account read back as `null` and silently skipped -auto-resume. - -No email is sent when a repurpose stops. `markAsTokenExpired()` and -`VerifyWorkspaceConnections` already email about the account, and reconnecting is -what auto-resumes the repurpose; deleting or switching an account off is -something the user just did, so the flash on the accounts page reports the count -instead. - -`VerifyWorkspaceConnections` is the **only** thing that promotes an account back -to `Connected`, because it does so after a real `verify()` call. A successful -token refresh is not that proof — the refresh token being valid says nothing -about whether publishing still works — so `RefreshSocialToken` must not promote, -even though it would let a paused repurpose resume sooner. - ## UI locale (`users.locale`) The user's UI language lives in the database, on `users.locale`, cast to @@ -501,3 +427,228 @@ locales, write a Mailable that passes data plus the three metadata strings, send with `Mail::to($user)`, run the Maizzle build, and cover it with a render test — `tests/Feature/Mail/MailRenderingTest.php` exists because copy moving into the view turns a forgotten variable into a runtime-only failure. + +## Icons (@tabler/icons-vue) + +- This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`. +- All Tabler icons are prefixed with `Icon`, e.g. `IconCheck`, `IconChevronRight`, `IconMail`. +- Import icons from `@tabler/icons-vue`: `import { IconCheck, IconX } from '@tabler/icons-vue'`. +- Browse available icons at https://tabler.io/icons + +## Dates + +- For date manipulation, always use `@/dayjs` (pre-configured dayjs instance with utc, timezone, relativeTime plugins). +- For formatting dates for display (formatDate, formatDateTime, formatTime, diffForHumans), always use `@/date` which centralizes all formatting logic with proper timezone handling. +- Never use raw `new Date()` for date calculations — use dayjs. + +## Routing (Wayfinder) + +- This project uses Laravel Wayfinder for type-safe frontend routing. +- ALWAYS use Wayfinder-generated route helpers in Vue pages (e.g. `register()`, `login()`, `dashboard()`). NEVER hardcode URL strings like `href="/register"`. +- After creating or modifying PHP routes/controllers, run `php artisan wayfinder:generate` to regenerate the TypeScript route helpers. +- Import routes from `@/routes/...` (e.g. `import { store } from '@/routes/login'`). + +## Pagination + +- Always use normal pagination (`->paginate()`). NEVER use cursor pagination (`->cursorPaginate()`). +- All paginated lists must use Inertia's scroll pagination (`Inertia::scroll()` on the backend with `` on the frontend). NEVER use traditional page-based pagination with page links/buttons. +- The page size ALWAYS comes from `config('app.pagination.default')` — never a magic number, and never a `perPage`/`per_page` value supplied by the request or frontend. Action/service list methods must NOT accept a `$perPage` parameter; call `->paginate((int) config('app.pagination.default'))` directly. + - **This includes the public REST API** (`app/Http/Controllers/Api`). It used to pin its own page size of 15 as a stable contract; that exception is gone, so a list endpoint reads the same config as everything else. Changing `app.pagination.default` therefore changes the API's page size too — deliberate, and the reason a list response always carries `meta.per_page` for clients to read rather than assume. + +## Form Validation + +- NEVER use HTML5 validation attributes (`required`, `minlength`, `pattern`, etc.) on form inputs. Always rely solely on backend validation. + +## Backend Validation + +- Validation rules always live in a dedicated `Illuminate\Foundation\Http\FormRequest` subclass under `app/Http/Requests/App//`. Controller actions must type-hint the FormRequest as the parameter — NEVER call `$request->validate([...])` inline in the controller. +- Naming: `Request.php` (e.g. `StorePostRequest`, `UpdatePostRequest`, `LinkPreviewRequest`). + +## Database engines (PostgreSQL + MySQL) + +TryPost runs on **both PostgreSQL and MySQL**. Cloud runs PostgreSQL; a self-hosted install may pick either. Every query, migration, and test must work on both — the suite is expected to be green on each. + +- **What the app supports is the intersection of the two engines, never the superset of one.** When they differ, take the narrower behaviour — a feature that only holds on PostgreSQL is a feature TryPost does not have. +- Never use an engine-specific operator or function. Search uses `whereLike()` (Laravel handles the case-insensitive form per driver), never `ilike` or a raw `LOWER(...)` comparison. +- Traps that only surface on MySQL: + - **JSON object key order is not preserved.** MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with `toEqual` (recursive, order-independent), never `toBe`/`assertSame`. Array *element* order is preserved on both. + - **`$table->timestamp()` tops out at 2038-01-19.** PostgreSQL has no such limit, so 2038-01-19 is the app's ceiling: nothing written to a `timestamp()` column may go past it — scheduled posts, expiry sentinels and test fixtures alike. `2037-12-31` reads as "far future" and works on both. Do not widen a column to escape the limit without a deliberate decision; it changes what self-hosted MySQL installs can store. + - **Raw query-builder reads carry no Eloquent cast**, so the driver's native shape leaks through: `DB::table(...)->value('some_bool')` is `true` on PostgreSQL and `1` on MySQL. Read through the model, or use `assertDatabaseHas`. + - **Identifier quoting differs** — PostgreSQL emits `"post_platforms"`, MySQL emits backticks. Never match logged SQL (`DB::listen`) against a quoted identifier. + - **MySQL refuses to drop the only index backing a foreign key** (SQLSTATE `1553`). A migration `down()` that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first. + - **DDL implicitly commits**, which defeats `RefreshDatabase`'s rollback: schema changes made inside a test leak into the tests that follow. Keep them idempotent. + +## Per-Platform Post Meta (`PostPlatform.meta`) + +- All `platforms.*.meta` validation (the parent array rule AND every per-platform sub-key: `aspect_ratio`, TikTok `privacy_level`/flags, Pinterest `board_id`, Discord `channel_id`/`mentions`/`embeds`, etc.) lives in ONE place: `App\Support\PostPlatformMetaRules`. + - Every post create/update entry point — web (`App\Http\Requests\App\Post\UpdatePostRequest`), public API (`App\Http\Requests\Api\Post\{Store,Update}PostRequest`), and MCP (`App\Mcp\Tools\Post\{Create,Update}PostTool`) — spreads `...PostPlatformMetaRules::rules()`. NEVER add a per-platform meta rule inline to a single request/tool. + - Why: `FormRequest::validated()` (and MCP `$request->validate()`) STRIPS any key without a rule. A meta field defined in only one entry point is silently dropped everywhere else — which is exactly how Discord/Pinterest/TikTok meta was lost via API/MCP before this was centralized. +- Required-on-publish (meta a platform needs to publish, e.g. Discord `channel_id`) also lives there: `addRequiredOnPublishErrors()` for request-driven flows (web/API update `withValidator`), `assertStoredPostPublishable()` for flows that publish stored state without resubmitting platforms (MCP `PublishPostTool`). Add new required-meta rules to `requiredMetaViolation()`, not inline. +- When adding a new platform's meta field, add it (and any publish requirement) to `PostPlatformMetaRules` ONLY, and cover it in `tests/Feature/Api/PostApiPlatformMetaTest.php` + `tests/Feature/Mcp/PostPlatformMetaToolTest.php`. + +## Media Types (image / video / document) + +- A media item is one of exactly three types: **image**, **video**, **document** (PDF). There is no standalone "audio" media type (audio exists only as a video voiceover input). +- Media-type detection lives in ONE place per side — NEVER hand-write `type === 'image'`, `mime_type === 'application/pdf'`, `mime.startsWith('video/')`, or extension checks inline. + - Backend: `App\Enums\Media\Type` — `classify()`, `fromMime()`, `fromExtension()`, `isGif()`, plus the `allowedMimeTypes()` / `extensions()` allow-lists. Use these, never a raw MIME/extension comparison. + - Frontend: `resources/js/lib/mediaType.ts` — the mirror of the backend enum: the `MediaType` union, `classify()`, `fromMimeType()` (for a browser `File.type`), `fromExtension()`, `isImage()`/`isVideo()`/`isDocument()`/`isGif()`. `@/composables/useMedia` re-exports `isImageMedia`/`isVideoMedia`/`isDocumentMedia` aliases for legacy call sites. + - Detection trusts the explicit `type` first, then the MIME, then the filename extension — so an item with only a MIME (e.g. AI/Unsplash/Giphy media without a `type`) still classifies correctly. A bare `item.type === 'image'` (with a `v-else` video) silently mis-renders those. +- The `type` field on every media-ish interface is the `MediaType` union, never `string` — `MediaItem`, and any sibling picked/asset/saved shape (`PickedMedia`, `AssetMedia`, `SavedMedia`, etc.). +- The upload `accept` attribute for "everything we allow" comes from `acceptAttribute()` (frontend) / `Media\Type::allowedMimeTypes()` (backend) — never a hardcoded MIME list. Per-capability `accept` builders driven by content-type rules (e.g. `image/*,video/*`) are fine; those aren't detection. + +## Pest / Feature Tests + +- ALWAYS use named routes via the `route()` helper in feature tests. NEVER hardcode URL strings like `'/posts/ai/create'`. + - Example: `$this->postJson(route('app.posts.store'))` instead of `$this->postJson('/posts')`. + - With params: `route('app.posts.ai.create.finalize', $creationId)`. + +## Browser Tests (Pest + Playwright) + +Browser tests live in `tests/Browser` and run on `pestphp/pest-plugin-browser` driving Playwright. **Laravel Dusk is not installed** — there is no `DuskTestCase`, no `$browser` object, and no `browse()`. Do not add `dusk="..."` attributes; they select nothing. + +- ALWAYS use named routes via `route()`. NEVER hardcode URLs like `'https://trypost.test/login'`. + - Example: `visit(route('login'))`. +- ALWAYS target elements by `data-testid`. NEVER use CSS classes (`.text-red-600`), tag names, or text strings. + - `@my-element` resolves to `[data-testid="my-element"]`, so add `data-testid="my-element"` in the Vue component and use `$page->click('@my-element')`. + - Bind it for repeated elements: `:data-testid="`connect-${platform.value}`"`. +- Assertions do NOT auto-wait on SPA paint. Wait for the element to mount and lay out first — see the `waitFor*TestId()` helper at the top of `tests/Browser/WelcomeConnectTest.php` and copy the pattern under a file-unique name (these helpers are global functions; a duplicated name collides across test files). +- **Never `sleep()` in a browser test.** The HTTP server that serves the page runs inside the same PHP process (an Amp loop that only ticks while Pest awaits Playwright), so a blocking `sleep()` starves every asset request: the page stays blank, the Vue app never mounts, and screenshots come out empty. Poll from the page with `$page->script(...)` (as the `waitFor*TestId()` helpers do) — that keeps the loop running. +- `BrowserTestCase` sets `$fakesVite = false` on purpose: these tests load real built assets, so faking Vite blanks the app. +- End page assertions with `->assertNoJavaScriptErrors()`. +- CI runs them un-parallelised (`php artisan test tests/Browser --compact`) against `npm run build` output, so keep them independent of a running dev server. + +## Array Data Access + +- In Action classes and similar service classes, ALWAYS use Laravel's `data_get()` helper instead of direct array access. + - Example: `data_get($data, 'name')` instead of `$data['name']`. + - Use the third parameter for fallback values: `data_get($data, 'username', $sender->username)` instead of `$data['username'] ?? $sender->username`. + +## Eloquent Models & Morph Map + +- EVERY Eloquent model in `app/Models` MUST be registered in `Relation::enforceMorphMap([...])` inside `AppServiceProvider::configureMorphMap()`, keyed by a camelCase alias (e.g. `'postPlatform' => PostPlatform::class`). +- When you add a new model, add it to the morph map in the same change. `tests/Unit/MorphMapTest.php` fails if any model is missing. +- The alias is persisted in polymorphic columns, so never rename or remove an existing alias for a model that has stored rows. + +## Imports + +- NEVER use inline class references (e.g., `\DB::listen`, `\Str::uuid()`). ALWAYS import classes at the top of the file with a `use` statement. + - PHP: `use Illuminate\Support\Facades\DB;` then `DB::listen(...)` + - TypeScript/Vue: `import { ref } from 'vue'` then `ref(...)` + +## API Response Status Codes + +- When returning JSON responses with explicit status codes, always use `Symfony\Component\HttpFoundation\Response` constants instead of magic numbers. + - Example: `Response::HTTP_CREATED` instead of `201`, `Response::HTTP_NO_CONTENT` instead of `204`. + +## String Interpolation + +- When injecting variables into strings, prefer **double-quoted interpolation** with curly braces over concatenation with `.`. + - PHP: `"workspace.{$workspace->id}"` instead of `'workspace.'.$workspace->id`. + - Use curly braces `{}` even for simple variables to keep the boundary explicit and to allow object/array access without ambiguity. + - Single quotes are still preferred when the string has no interpolation. + +## External Service URLs + +- NEVER hardcode third-party API hosts, OAuth endpoints, or per-platform service URLs (e.g. `https://api.x.com/2`, `https://www.linkedin.com/oauth/v2/accessToken`, `https://bsky.social`). They live in `config/trypost.php` under `platforms.` with a matching `env(...)` default, so self-hosted users can override them and we have a single source of truth. + - Production code: `config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken'`, never the literal URL. + - Tests: use the same `config(...)` value in `Http::fake([...])` — `Http::fake([config('trypost.platforms.x.api').'/oauth2/token' => ...])`. Tests with hardcoded URLs drift silently when the config changes. + - Path/route segments after the host (e.g. `/oauth/v2/accessToken`, `/xrpc/com.atproto.server.refreshSession`) are part of the provider's protocol spec — those stay inline next to the call. Only the host comes from config. + +## Social Platform API Documentation (official sources) + +**Always consult the official docs below before implementing or changing OAuth, publishing, deletion, rate-limit, or any other platform-specific behavior — never guess endpoints, scopes, rate limits, or capabilities from memory.** APIs shift over time; a behavior confirmed in a past session may no longer hold. One entry per social network we integrate with: + +- **Facebook / Instagram / Threads (Meta)**: all three share the Graph API error format (`error.code`, `error.type`). + - General error handling / codes 1, 2, 4, 17, 190: https://developers.facebook.com/docs/graph-api/guides/error-handling/ + - Rate limiting — Platform Rate Limits (app/user tokens, codes 4/17) vs. Business Use Case (BUC) Rate Limits (Page/system-user tokens, codes 80000–80014 — e.g. `80001` Pages API, `80002` Instagram Platform; BUC rejections come back as plain HTTP 400, not 429): https://developers.facebook.com/docs/graph-api/overview/rate-limiting/ + - Instagram content-publishing error codes: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/ + - Instagram media reference (incl. `DELETE`): https://developers.facebook.com/docs/instagram-platform/reference/instagram-media/ + - Threads API: https://developers.facebook.com/docs/threads — reuses the Graph API error format; no separate Threads-specific error code table exists. Delete posts (needs the separate `threads_delete` permission, 100 deletes/day/account): https://developers.facebook.com/docs/threads/posts/delete-posts/ + - Our `App\Services\Social\Meta\GraphError` (used by `ConnectionVerifier`'s verify/refresh calls) has the full rationale and code table in its class docblock — check there before changing transient-vs-confirmed-rejection classification. + - `Facebook`/`InstagramFacebook` `SocialAccount`s use a Facebook Page access token (BUC-limited); `Instagram` (direct login) and `Threads` use a user access token (Platform Rate Limit-limited). This affects which rate-limit codes apply to which platform. +- **X (Twitter)**: API v2 — https://docs.x.com/x-api ; Post management (create/delete) — https://docs.x.com/x-api/posts/manage-tweets/introduction +- **LinkedIn**: Posts API (create/update/delete, member + organization) — https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api (replaces the deprecated `ugcPosts` API) +- **Mastodon**: Statuses API — https://docs.joinmastodon.org/methods/statuses/ +- **Pinterest**: API v5 reference — https://developers.pinterest.com/docs/api/v5/ +- **YouTube**: Data API v3 — https://developers.google.com/youtube/v3/docs +- **TikTok**: Content Posting API — https://developers.tiktok.com/doc/content-posting-api-reference-direct-post — **no delete/unpublish endpoint exists**; a published post can only be removed manually inside the TikTok app +- **Bluesky / AT Protocol**: official lexicons — https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo ; HTTP API reference — https://docs.bsky.app +- **Discord**: Webhook resource (used for our webhook-based publishing) — https://docs.discord.com/developers/resources/webhook +- **Telegram**: Bot API — https://core.telegram.org/bots/api +- **Google Business Profile**: Business Information API, Account Management API, Business Profile Performance API — https://developers.google.com/my-business/reference/rest ; legacy but still-active Local Posts v4 API (the only endpoint for creating/updating/deleting Local Posts) — https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts + +## TryPost.it Documentation + +- All our documentation to final user it's under https://docs.trypost.it + +## X link defusing (env knob) + +X bills a post containing a URL at **$0.20** vs **$0.015** for a plain post (13x), and its algorithm demotes link posts. So on Cloud the `ContentSanitizer` rewrites every URL in the X version of a post into a non-clickable form — `https://example.com/post` becomes `example(.)com/post`. + +| Env | Config | Default | Effect | +| --- | --- | --- | --- | +| `X_DEFUSE_LINKS` | `trypost.platforms.x.defuse_links` | `false` | `true`: URLs in the X version of a post are rewritten non-clickable (scheme and `www.` dropped, **every** dot of the host replaced with `(.)`). `false`: the X content is published unchanged. Only affects `Platform::X` — every other network keeps the URL intact. | + +Standing constraints: +- The transform lives in ONE place: the `Platform::X` arm of `App\Services\Social\ContentSanitizer::sanitize()`. Never re-implement it in a publisher or add a `$defuseLinks` parameter to `sanitize()` — a per-call-site flag gets forgotten at the next entry point and we silently start paying again. Because `PostPreviewer` also goes through `ContentSanitizer`, the app/API/MCP previews show the defused text for free. +- **Every** dot of the host must be broken. Defusing only the dot before the TLD leaves `blog.example.com` in `blog.example.com(.)br`, which X still detects and bills. +- A URL carrying `https://`, `http://` or `www.` is defused on sight. A **bare** host is only a link when its last label is a delegated TLD — that check is the one thing separating `acme.com` from `Node.js`, and it goes through `App\Support\LinkTlds`, which mirrors the full IANA root zone rather than a hand-picked subset. Never replace it with "any 2+ letters after a dot", and never trim it back to a curated list: whatever X links is what X bills, so the two must stay in step. `README.md` and `backup.zip` are defused on purpose — `.md` and `.zip` are real TLDs and X links them too. +- Off by default everywhere. Cloud opts in; self-hosted installs publish through their own X app and pay their own bill, so they only turn it on if they want to. +- Character limits are measured against the **sanitized** content — the string the publisher actually sends — in both `App\Rules\ContentFitsPlatformLimits` (save/schedule) and `HasSocialHttpClient::validateContentLength()` (publish). The editor stores HTML and per-platform rules change the length again, so measuring the raw draft blocks saving posts that publish fine and lets through posts the network rejects. Keep the two in step. +- Tests enable it explicitly with `config()->set('trypost.platforms.x.defuse_links', true)` rather than pinning an env, so the suite runs against the shipped default. +- The editor counts characters and renders the X preview client-side, so the rewrite is mirrored in `resources/js/lib/defuseXLinks.ts`. The TLD list is NOT duplicated there: `PostController@edit` sends `App\Support\LinkTlds::all()` as the `xLinkTlds` page prop, and only when defusing is on — an empty set means the feature is off, since without the list a bare host cannot be told from `Node.js`. Do not move it to the Inertia shared props; only the editor needs it. Two tests keep the mirror honest: `XLinkDefusingParityTest` runs a shared corpus through both engines over the same list and diffs the output, and `tests/Browser/XLinkDefusingTest.php` drives the real editor. +- Neither expression may use lookbehind. Safari only understands it from 16.4, esbuild cannot transpile it, and a `SyntaxError` there takes down the whole chunk — the character before a candidate URL is consumed and put back instead. + +## Git + +- NEVER add `Co-Authored-By` lines to commit messages. +- NEVER commit, push, or open PRs unless explicitly asked by the user. +- Always create a new branch for feature work before making changes. + +## Repurpose account health + +A repurpose depends on social accounts it does not own the lifecycle of. Three +decisions govern how it reacts, and each exists because the obvious alternative +was tried and was wrong. + +- **A switched-off destination is skipped, never an error.** Deactivating an + account means "don't post here", which `ProcessRepurposeItem` already honours. + So `ActivateRepurpose::assertDestinationsPublishable()` requires **one** usable + destination, not all of them, and the destination rule in the repurpose + FormRequests carries **no** `is_active` clause. Requiring either is what used + to block editing *and* resuming any repurpose that listed a paused account. + Keep the `workspace_id` clause — that is tenancy, not health. The + `source_social_account_id` rules stay strict: a source genuinely must work. +- **`repurposes.paused_reason` is not UI copy.** NULL means the user paused it. + Its only two jobs are deciding the watermark on resume (a system pause starts + from `now()`, a user pause keeps its place) and deciding whether the system may + auto-resume. Banners derive from current account health instead, so they can + say "ready to resume" once the cause is fixed. **Never clear it in + `UpdateRepurpose`** — that destroys the record that the pause was systemic, and + the next Resume replays the entire backlog. +- **Source and destination are deliberately asymmetric.** A dead source stops the + automation; a dead destination keeps flowing to the publisher, which fails the + post visibly and lets the user retry it after reconnecting. Skipping a + destination at job time would be permanent for that item, since items are never + retried. + +`RepurposeAccountSync` runs from `SocialAccountObserver` and must never throw: +`deleting` runs inside `$account->delete()`, and `persistIdentity()` wraps a +reconnect in a transaction, so an exception there would 500 a disconnect or roll +back a reconnect. It reads account health **from the database**, not from the +model it was handed — `is_active` is absent from `SocialAccountFactory`, and +strict mode exempts recently-created models from the missing-attribute +exception, so a healthy account read back as `null` and silently skipped +auto-resume. + +No email is sent when a repurpose stops. `markAsTokenExpired()` and +`VerifyWorkspaceConnections` already email about the account, and reconnecting is +what auto-resumes the repurpose; deleting or switching an account off is +something the user just did, so the flash on the accounts page reports the count +instead. + +`VerifyWorkspaceConnections` is the **only** thing that promotes an account back +to `Connected`, because it does so after a real `verify()` call. A successful +token refresh is not that proof — the refresh token being valid says nothing +about whether publishing still works — so `RefreshSocialToken` must not promote, +even though it would let a paused repurpose resume sooner. diff --git a/ANALYTIC.md b/ANALYTIC.md new file mode 100644 index 000000000..f887445ed --- /dev/null +++ b/ANALYTIC.md @@ -0,0 +1,30 @@ +# Analytics por rede social + +Este documento descreve as métricas que o TryPost consegue consultar atualmente por conta e por publicação. + +> Este é um inventário do comportamento atual, anterior ao novo módulo. O desenho aprovado está em `docs/superpowers/specs/2026-09-23-workspace-follower-analytics-design.md` e o plano executável está em `docs/superpowers/plans/2026-09-23-workspace-analytics-backfill.md`. Na V1 nova, LinkedIn, Telegram, Discord e Google Business Profile ficam fora de todas as superfícies de analytics; LinkedIn fica planejado para V2. + +| Rede / integração | Métricas por conta | Métricas por publicação | Observações | +| --- | --- | --- | --- | +| TikTok | Seguidores; seguindo; curtidas totais; quantidade de vídeos; visualizações, curtidas, comentários e compartilhamentos agregados dos vídeos recentes | Visualizações; curtidas; comentários; compartilhamentos | O agregado da conta considera os 20 vídeos mais recentes. O seletor de período não é aplicado a essa consulta. Posts privados podem não fornecer um ID público consultável. | +| Instagram (conexão direta ou via Facebook) | Alcance; seguidores; curtidas; comentários; compartilhamentos; salvamentos; visualizações; interações | **Feed:** alcance, curtidas, comentários, compartilhamentos, salvamentos e interações.
**Reel:** alcance, curtidas, comentários, compartilhamentos, salvamentos e visualizações.
**Story:** alcance, visualizações e respostas. | Disponível no painel por conta e no detalhe da publicação. | +| Threads | Visualizações; curtidas; respostas; reposts; citações | Visualizações; curtidas; respostas; reposts; citações | Disponível no painel por conta e no detalhe da publicação. | +| Facebook Page | Alcance da página; alcance dos posts; engajamento dos posts; novos seguidores; visualizações da página | **Feed:** impressões, alcance, curtidas e cliques.
**Story:** impressões, alcance, interações, reações, respostas e compartilhamentos.
**Vídeo/Reel:** reproduções, reações e interações. | As métricas disponíveis dependem do tipo e do identificador da publicação. | +| X | Impressões; curtidas; reposts; respostas; citações; bookmarks | Impressões; curtidas; reposts; respostas; citações; bookmarks | A consulta da conta soma as métricas dos posts encontrados no período, com limite de 100 dias e de cinco páginas de resultados. | +| LinkedIn — perfil pessoal | Não disponível no painel por conta | Curtidas; comentários | A API usada pela integração de perfil pessoal não fornece ao TryPost o conjunto completo de analytics disponível para páginas. | +| LinkedIn — página de empresa | Visualizações da página; novos seguidores orgânicos; novos seguidores pagos; impressões; cliques; curtidas; comentários; compartilhamentos | Impressões; cliques; curtidas; comentários; compartilhamentos | Métricas com valor zero podem ser omitidas no painel por conta. | +| Pinterest | Impressões; cliques no Pin; engajamentos; salvamentos; taxa média de clique | Impressões; salvamentos; cliques no Pin; cliques externos; visualizações de vídeo | A consulta por publicação usa uma janela fixa dos últimos 90 dias. | +| YouTube Shorts | Visualizações; minutos assistidos; duração média da visualização; percentual médio assistido; inscritos ganhos; inscritos perdidos; curtidas | Visualizações; minutos assistidos; duração média da visualização; curtidas; comentários; compartilhamentos | As métricas da publicação são consultadas desde a data de publicação até o dia atual. | +| Telegram | Número de inscritos do canal | Número de inscritos do canal; reações separadas por emoji | A Bot API não fornece visualizações das mensagens para bots. As reações são recebidas pelo webhook e armazenadas nos metadados da publicação. | +| Bluesky | Não disponível no painel por conta | Curtidas; reposts; citações; respostas | Atualmente existe apenas analytics por publicação. | +| Mastodon | Não disponível no painel por conta | Favoritos; boosts/reblogs; respostas | Atualmente existe apenas analytics por publicação. | +| Discord | Quantidade aproximada de membros do servidor, disponível no serviço interno, mas ainda não exibida no painel geral | Quantidade aproximada de membros; reações separadas por emoji; respostas na thread | O Discord não fornece impressões, alcance ou visualizações para mensagens de bot. | +| Google Business Profile | Impressões no Search em desktop; impressões no Search em mobile; impressões no Maps em desktop; impressões no Maps em mobile; cliques no site; cliques para ligar; solicitações de rota; conversas; palavras-chave de busca; quando aplicável, agendamentos, pedidos de comida e cliques no cardápio | Não disponível | Palavras-chave são agregadas mensalmente. Contagens de termos com baixo volume podem ser estimadas. Agendamentos e métricas de comida com valor zero são ocultados. | + +## Disponibilidade atual + +O painel geral de analytics permite selecionar contas de TikTok, Instagram, Threads, Facebook, X, LinkedIn Page, Pinterest, YouTube, Telegram e Google Business Profile. + +LinkedIn pessoal, Bluesky e Mastodon possuem apenas métricas por publicação. O Discord também possui métricas implementadas por publicação e uma métrica de conta, mas ainda não aparece no painel geral. + +As métricas por publicação só são consultadas quando a publicação está com status `published` e possui um identificador retornado pela plataforma. Esses resultados ficam em cache por cinco minutos. As métricas do painel por conta usam, em geral, o período selecionado e ficam em cache por uma hora em produção. diff --git a/app/Services/Ai/RecordAiUsage.php b/app/Actions/Ai/RecordAiUsage.php similarity index 70% rename from app/Services/Ai/RecordAiUsage.php rename to app/Actions/Ai/RecordAiUsage.php index 185459e3b..47a3c809b 100644 --- a/app/Services/Ai/RecordAiUsage.php +++ b/app/Actions/Ai/RecordAiUsage.php @@ -2,31 +2,18 @@ declare(strict_types=1); -namespace App\Services\Ai; +namespace App\Actions\Ai; use App\Enums\Ai\UsageType; use App\Models\AiUsageLog; use App\Models\Workspace; +use App\Services\Ai\CreditCost; use Illuminate\Support\Facades\Log; use Throwable; -/** - * Persists an AI usage row and debits credits from the account's monthly - * quota. Credits are billed at the account level (Workspace::account_id); - * workspace_id is recorded for analytics. - * - * Wraps the create in a try/catch so a tracking failure NEVER bubbles up - * and breaks the actual AI flow — at worst we miss a usage row and the - * user gets unblocked. - */ final class RecordAiUsage { - /** - * Record a usage entry for a text generation. Credits are computed from - * total_tokens via CreditCost::forText(). - * - * @param array $metadata - */ + /** @param array $metadata */ public static function recordText( Workspace $workspace, int $promptTokens, @@ -38,12 +25,11 @@ public static function recordText( array $metadata = [], ): void { $totalTokens = $promptTokens + $completionTokens; - $credits = CreditCost::forText($totalTokens); self::persist( workspace: $workspace, type: UsageType::Text, - credits: $credits, + credits: CreditCost::forText($totalTokens), provider: $provider, model: $model, promptTokens: $promptTokens, @@ -55,12 +41,7 @@ public static function recordText( ); } - /** - * Record a usage entry for an AI image generation (gpt-image-* etc.). - * Credits are flat per call via CreditCost::forImage($model). - * - * @param array $metadata - */ + /** @param array $metadata */ public static function recordImage( Workspace $workspace, string $provider, @@ -69,12 +50,10 @@ public static function recordImage( ?string $postId = null, array $metadata = [], ): void { - $credits = CreditCost::forImage($model); - self::persist( workspace: $workspace, type: UsageType::Image, - credits: $credits, + credits: CreditCost::forImage($model), provider: $provider, model: $model, promptTokens: 0, @@ -86,12 +65,7 @@ public static function recordImage( ); } - /** - * Record a usage entry for an image-template generation. Templates do not - * call an LLM (composed via Unsplash + branding) so we charge zero credits. - * - * @param array $metadata - */ + /** @param array $metadata */ public static function recordTemplate( Workspace $workspace, ?string $provider = null, @@ -114,9 +88,7 @@ public static function recordTemplate( ); } - /** - * @param array $metadata - */ + /** @param array $metadata */ private static function persist( Workspace $workspace, UsageType $type, @@ -145,11 +117,11 @@ private static function persist( 'credits' => $credits, 'metadata' => $metadata !== [] ? $metadata : null, ]); - } catch (Throwable $e) { + } catch (Throwable $exception) { Log::warning('Failed to record AI usage', [ 'workspace_id' => $workspace->id, 'type' => $type->value, - 'error' => $e->getMessage(), + 'error' => $exception->getMessage(), ]); } } diff --git a/app/Actions/Analytics/AdvanceAnalyticsSyncState.php b/app/Actions/Analytics/AdvanceAnalyticsSyncState.php new file mode 100644 index 000000000..946463e16 --- /dev/null +++ b/app/Actions/Analytics/AdvanceAnalyticsSyncState.php @@ -0,0 +1,316 @@ +lockForUpdate()->find($stateId); + + if (! $state + || ($socialAccountId !== null && $state->social_account_id !== $socialAccountId) + || ($state->isTerminal() && ! $restartTerminal)) { + return null; + } + + $checkpoint = $state->checkpoint ?? []; + $revision = ((int) data_get($checkpoint, 'revision', 0)) + 1; + $cursor = $restartTerminal && $state->isTerminal() + ? null + : data_get($checkpoint, 'cursor'); + $seenCount = $restartTerminal && $state->isTerminal() + ? 0 + : (int) data_get($checkpoint, 'seen_count', 0); + + $state->update([ + 'status' => SyncStatus::Running, + 'checkpoint' => [ + 'cursor' => $cursor, + 'revision' => $revision, + ...($state->collector === SyncCollector::PublicationBackfill && $state->socialAccount?->platform === Platform::X + ? ['seen_count' => $seenCount] + : []), + ...(data_get($checkpoint, 'resumed_after_disconnect') + ? ['resumed_after_disconnect' => true] + : []), + ...(data_get($checkpoint, 'had_provider_limit') + ? ['had_provider_limit' => true] + : []), + ...(! $restartTerminal && data_get($checkpoint, 'invalid_cursor_resets') + ? ['invalid_cursor_resets' => (int) data_get($checkpoint, 'invalid_cursor_resets')] + : []), + ], + 'last_error_category' => null, + ]); + + $cutoff = $state->collector === SyncCollector::PublicationBackfill + ? ($state->target_since ?? CarbonImmutable::now('UTC')->subDays(365)) + : ($state->high_watermark_at ?? CarbonImmutable::now('UTC'))->subDays(3); + + return [ + 'cursor' => is_string($cursor) && $cursor !== '' ? $cursor : null, + 'revision' => $revision, + 'cutoff' => $cutoff->toImmutable(), + ]; + }); + } + + /** + * Persist page facts even for a stale worker, but only let the worker that + * owns the current revision advance the provider cursor. + * + * @return array{advanced: bool, terminal: bool} + */ + public function handle( + string $stateId, + int $capturedRevision, + SocialAccount $account, + PublicationPage $page, + ): array { + return DB::transaction(function () use ($account, $capturedRevision, $page, $stateId): array { + $state = AnalyticsSyncState::query()->lockForUpdate()->find($stateId); + + if (! $state || $state->social_account_id !== $account->id) { + return ['advanced' => false, 'terminal' => true]; + } + + $identity = $page->publications === [] ? null : TryPostPublicationIdentity::fromAccount( + $account, + $this->accountKeys->for($account), + ); + + foreach ($page->publications as $publication) { + $this->publications->external($account, $publication, $identity); + } + + $checkpoint = $state->checkpoint ?? []; + + if ((int) data_get($checkpoint, 'revision', 0) !== $capturedRevision) { + return ['advanced' => false, 'terminal' => $state->isTerminal()]; + } + + $publishedAt = collect($page->publications)->pluck('publishedAt'); + $pageOldest = $publishedAt->min(); + $pageNewest = $publishedAt->max(); + $oldest = $this->earlier($state->oldest_reached_at, $pageOldest); + $highWatermark = $this->later($state->high_watermark_at, $pageNewest); + $reachedTarget = $state->collector === SyncCollector::PublicationBackfill + && $page->canStopAtTarget + && $oldest + && $state->target_since + && $oldest->lessThanOrEqualTo($state->target_since); + $isXBackfill = $state->collector === SyncCollector::PublicationBackfill + && $account->platform === Platform::X; + $seenCount = (int) data_get($checkpoint, 'seen_count', 0) + count($page->publications); + $xTimelineLimited = $isXBackfill + && $page->providerExhausted + && ! $reachedTarget + && $state->target_since + && $oldest + && $oldest->greaterThan($state->target_since) + && $seenCount >= self::X_TIMELINE_LIMIT; + $hadProviderLimit = $page->providerLimited || data_get($checkpoint, 'had_provider_limit'); + $finished = $page->providerExhausted || $reachedTarget; + + $status = match (true) { + $xTimelineLimited, $hadProviderLimit && $finished => SyncStatus::ProviderLimited, + filled($page->partialReason) && $finished => SyncStatus::Partial, + $finished => SyncStatus::Complete, + default => SyncStatus::Running, + }; + + $state->update([ + 'status' => $status, + 'checkpoint' => [ + 'cursor' => $status === SyncStatus::Running ? $page->nextCursor : null, + 'revision' => $capturedRevision, + ...($isXBackfill ? ['seen_count' => $seenCount] : []), + ...($hadProviderLimit && $status === SyncStatus::Running ? ['had_provider_limit' => true] : []), + ...($status === SyncStatus::Running && data_get($checkpoint, 'invalid_cursor_resets') + ? ['invalid_cursor_resets' => (int) data_get($checkpoint, 'invalid_cursor_resets')] + : []), + ], + 'oldest_reached_at' => $oldest, + 'high_watermark_at' => $highWatermark, + 'last_success_at' => CarbonImmutable::now('UTC'), + 'last_error_category' => match (true) { + $hadProviderLimit => 'provider_limited', + $xTimelineLimited => 'x_timeline_3200', + default => $page->partialReason, + }, + ]); + + if ($state->collector === SyncCollector::PublicationBackfill && $status !== SyncStatus::Running) { + $this->initializeDiscovery($account, $highWatermark); + } + + return ['advanced' => true, 'terminal' => $status !== SyncStatus::Running]; + }); + } + + public function recordFailure(string $stateId, int $capturedRevision, string $category, bool $terminal, ?string $socialAccountId = null): void + { + DB::transaction(function () use ($capturedRevision, $category, $socialAccountId, $stateId, $terminal): void { + $state = AnalyticsSyncState::query()->lockForUpdate()->find($stateId); + + if (! $state + || ($socialAccountId !== null && $state->social_account_id !== $socialAccountId) + || (int) data_get($state->checkpoint, 'revision', 0) !== $capturedRevision) { + return; + } + + $state->update([ + 'status' => $terminal ? SyncStatus::Failed : SyncStatus::Running, + 'last_error_category' => mb_substr($category, 0, 64), + ]); + }); + } + + public function resetInvalidCursor(string $stateId, int $capturedRevision, ?string $socialAccountId = null): bool + { + return DB::transaction(function () use ($capturedRevision, $socialAccountId, $stateId): bool { + $state = AnalyticsSyncState::query()->lockForUpdate()->find($stateId); + + if (! $state + || ($socialAccountId !== null && $state->social_account_id !== $socialAccountId) + || (int) data_get($state->checkpoint, 'revision', 0) !== $capturedRevision) { + return false; + } + + $resets = (int) data_get($state->checkpoint, 'invalid_cursor_resets', 0); + + if ($resets >= 1) { + $state->update([ + 'status' => $state->collector === SyncCollector::PublicationBackfill + ? SyncStatus::Partial + : SyncStatus::Failed, + 'last_error_category' => 'invalid_cursor_repeated', + ]); + + return false; + } + + $state->update([ + 'status' => SyncStatus::Pending, + 'checkpoint' => [ + 'cursor' => null, + 'revision' => $capturedRevision, + ...(array_key_exists('seen_count', $state->checkpoint ?? []) ? ['seen_count' => 0] : []), + ...(! empty(data_get($state->checkpoint, 'had_provider_limit')) ? ['had_provider_limit' => true] : []), + 'invalid_cursor_resets' => $resets + 1, + ], + 'last_error_category' => 'invalid_cursor', + ]); + + return true; + }); + } + + public function stopExpiredReconnectionCursor(string $stateId, int $capturedRevision, SocialAccount $account): ?string + { + return DB::transaction(function () use ($account, $capturedRevision, $stateId): ?string { + $state = AnalyticsSyncState::query()->lockForUpdate()->find($stateId); + + if (! $state + || $state->social_account_id !== $account->id + || (int) data_get($state->checkpoint, 'revision', 0) !== $capturedRevision + || ! data_get($state->checkpoint, 'resumed_after_disconnect') + || $state->oldest_reached_at === null) { + return null; + } + + $state->update([ + 'status' => SyncStatus::Partial, + 'checkpoint' => [ + 'cursor' => null, + 'revision' => $capturedRevision, + ...(array_key_exists('seen_count', $state->checkpoint ?? []) + ? ['seen_count' => (int) data_get($state->checkpoint, 'seen_count')] + : []), + ], + 'last_error_category' => 'reconnect_cursor_expired', + ]); + + return $this->initializeDiscovery($account, $state->high_watermark_at)->id; + }); + } + + private function initializeDiscovery(SocialAccount $account, ?CarbonImmutable $highWatermark): AnalyticsSyncState + { + $latest = AnalyticsPublication::query() + ->where('social_account_id', $account->id) + ->max('provider_published_at'); + $initialHighWatermark = $highWatermark + ?? ($latest ? CarbonImmutable::parse($latest, 'UTC') : CarbonImmutable::now('UTC')); + + $state = AnalyticsSyncState::query() + ->where('social_account_id', $account->id) + ->forCollector(SyncCollector::PublicationDiscovery) + ->first() + ?? AnalyticsSyncState::query()->firstOrCreate([ + ...AnalyticsSyncState::identityFor($account), + 'collector' => SyncCollector::PublicationDiscovery, + ], [ + 'social_account_id' => $account->id, + 'status' => SyncStatus::Pending, + 'checkpoint' => ['cursor' => null, 'revision' => 0], + ]); + + if ($state->workspace_id === null) { + $state->update(AnalyticsSyncState::identityFor($account)); + } + + if (! $state->high_watermark_at || $initialHighWatermark->greaterThan($state->high_watermark_at)) { + $state->update(['high_watermark_at' => $initialHighWatermark]); + } + + return $state; + } + + private function earlier(?CarbonImmutable $current, mixed $candidate): ?CarbonImmutable + { + if (! $candidate) { + return $current; + } + + $candidate = CarbonImmutable::parse($candidate, 'UTC'); + + return ! $current || $candidate->lessThan($current) ? $candidate : $current; + } + + private function later(?CarbonImmutable $current, mixed $candidate): ?CarbonImmutable + { + if (! $candidate) { + return $current; + } + + $candidate = CarbonImmutable::parse($candidate, 'UTC'); + + return ! $current || $candidate->greaterThan($current) ? $candidate : $current; + } +} diff --git a/app/Actions/Analytics/BuildFollowerAnalyticsReport.php b/app/Actions/Analytics/BuildFollowerAnalyticsReport.php new file mode 100644 index 000000000..6f8849041 --- /dev/null +++ b/app/Actions/Analytics/BuildFollowerAnalyticsReport.php @@ -0,0 +1,120 @@ +} */ + public function execute(Workspace $workspace, DateRange $previous, DateRange $current): array + { + $rows = $this->rows($workspace, $previous->end, $current); + $connectedAccounts = SocialAccount::query() + ->where('workspace_id', $workspace->id) + ->connected() + ->active() + ->includedInAnalytics() + ->where('created_at', '<=', $current->end->endOfDay()) + ->get(['platform', 'platform_user_id', 'created_at']); + $currentTotal = $this->total($rows, $current->end, $connectedAccounts); + $previousTotal = $this->total($rows, $previous->end, $connectedAccounts); + + return [ + 'current_total' => $currentTotal, + 'previous_total' => $previousTotal, + 'followers' => $this->followers($rows, $current, $currentTotal), + ]; + } + + private function rows(Workspace $workspace, CarbonImmutable $previousEnd, DateRange $range): Collection + { + return DB::table('analytics_account_daily_snapshots') + ->where('workspace_id', $workspace->id) + ->whereIn('platform', Platform::analyticsValues()) + ->where(function ($query) use ($previousEnd, $range): void { + $query->whereDate('date', $previousEnd->toDateString()) + ->orWhereBetween('date', [$range->start->toDateString(), $range->end->toDateString()]); + }) + ->select([ + 'social_account_key', 'social_account_id', 'platform', 'network', 'platform_user_id', + 'account_display_name', 'account_username', 'account_avatar_url', + 'date', 'followers_count', 'provenance', 'precision', 'collected_at', + ]) + ->orderBy('date') + ->get(); + } + + private function total(Collection $rows, CarbonImmutable $date, Collection $connectedAccounts): ?int + { + $onDate = $rows->filter(fn (object $row): bool => substr((string) $row->date, 0, 10) === $date->toDateString() + && $row->followers_count !== null); + + foreach ($connectedAccounts as $account) { + if (CarbonImmutable::parse($account->created_at, 'UTC')->greaterThan($date->endOfDay())) { + continue; + } + + if (! $onDate->contains(fn (object $row): bool => $row->network === $account->platform->network() + && $row->platform_user_id === $account->platform_user_id)) { + return null; + } + } + + return $onDate->isEmpty() ? null : (int) $onDate->sum('followers_count'); + } + + /** @return array */ + private function followers(Collection $rows, DateRange $range, ?int $total): array + { + $current = $rows->filter(fn (object $row): bool => substr((string) $row->date, 0, 10) >= $range->start->toDateString() + && substr((string) $row->date, 0, 10) <= $range->end->toDateString()); + $byAccount = $current->groupBy('social_account_key'); + $accounts = []; + + foreach ($byAccount as $key => $values) { + $first = $values->first(); + $last = $values->last(); + $end = $values->first(fn (object $row): bool => substr((string) $row->date, 0, 10) === $range->end->toDateString()); + $accounts[] = [ + 'social_account_key' => $key, + 'social_account_id' => $last->social_account_id, + 'platform' => $last->platform, + 'network' => $last->network, + 'name' => $last->account_display_name, + 'username' => $last->account_username, + 'avatar_url' => $last->account_avatar_url, + 'value' => $end?->followers_count === null ? null : (int) $end->followers_count, + 'growth' => $values->count() > 1 && $first->followers_count !== null && $last->followers_count !== null + ? (int) $last->followers_count - (int) $first->followers_count : null, + 'provenance' => $end?->provenance, + ]; + } + + usort($accounts, fn (array $a, array $b): int => [data_get($a, 'platform'), data_get($a, 'username'), data_get($a, 'social_account_key')] + <=> [data_get($b, 'platform'), data_get($b, 'username'), data_get($b, 'social_account_key')]); + $series = []; + $byDate = $current->groupBy(fn (object $row): string => substr((string) $row->date, 0, 10)); + + for ($day = $range->start; $day->lessThanOrEqualTo($range->end); $day = $day->addDay()) { + $date = $day->toDateString(); + $values = array_fill_keys(array_column($accounts, 'social_account_key'), null); + + foreach ($byDate->get($date, collect()) as $row) { + $values[$row->social_account_key] = $row->followers_count === null ? null : (int) $row->followers_count; + } + + $series[] = ['date' => $date, 'accounts' => $values]; + } + + return ['total' => $total, 'accounts' => $accounts, 'series' => $series]; + } +} diff --git a/app/Actions/Analytics/BuildPublicationAnalyticsReport.php b/app/Actions/Analytics/BuildPublicationAnalyticsReport.php new file mode 100644 index 000000000..10a84510d --- /dev/null +++ b/app/Actions/Analytics/BuildPublicationAnalyticsReport.php @@ -0,0 +1,276 @@ + */ + public function execute(Workspace $workspace, DateRange $previous, DateRange $current): array + { + $currentTotals = $this->emptyTotals(); + $previousTotals = $this->emptyTotals(); + $currentAccounts = []; + $previousAccounts = []; + $topReactions = []; + $topComments = []; + $buckets = $this->buckets->for($current); + $bucketIndexByDate = []; + $bucketCounts = []; + + foreach ($buckets as $index => $bucket) { + for ($day = CarbonImmutable::parse(data_get($bucket, 'start'), 'UTC'); $day->toDateString() <= data_get($bucket, 'end'); $day = $day->addDay()) { + $bucketIndexByDate[$day->toDateString()] = $index; + } + } + + foreach ($this->publications($workspace, $previous->start, $current->end) as $row) { + $key = $row->social_account_key; + + if (! $this->inRange($row->provider_published_at, $current)) { + $previousTotals = $this->addTotals($previousTotals, $row); + $previousAccounts[$key] = $this->addTotals( + data_get($previousAccounts, $key, $this->emptyTotals()), + $row, + ); + + continue; + } + + $currentTotals = $this->addTotals($currentTotals, $row); + $account = data_get($currentAccounts, $key, ['row' => $row, 'totals' => $this->emptyTotals()]); + $account['totals'] = $this->addTotals(data_get($account, 'totals'), $row); + $currentAccounts[$key] = $account; + $this->retainTopPublication($topReactions, $row, 'reactions_count'); + $this->retainTopPublication($topComments, $row, 'comments_count'); + + $date = substr((string) $row->provider_published_at, 0, 10); + $index = data_get($bucketIndexByDate, $date); + + if ($index !== null) { + $bucketCounts[$index][$key] = (int) data_get($bucketCounts, "{$index}.{$key}", 0) + 1; + } + } + + $postAccounts = []; + + foreach ($currentAccounts as $key => $account) { + $row = data_get($account, 'row'); + $postAccounts[] = [ + 'social_account_key' => $key, + 'platform' => $row->platform, + 'name' => $row->account_display_name, + 'username' => $row->account_username, + 'avatar_url' => $row->account_avatar_url, + 'count' => data_get($account, 'totals.posts'), + ]; + } + + foreach ($buckets as $index => &$bucket) { + $bucket['accounts'] = array_fill_keys(array_keys($currentAccounts), 0); + + foreach (data_get($bucketCounts, $index, []) as $key => $count) { + $bucket['accounts'][$key] = $count; + } + + $bucket['total'] = array_sum(data_get($bucket, 'accounts')); + } + unset($bucket); + + return [ + 'current_totals' => $this->finalizeTotals($currentTotals), + 'previous_totals' => $this->finalizeTotals($previousTotals), + 'posts' => [ + 'resolution' => $this->buckets->resolution($current), + 'accounts' => $postAccounts, + 'buckets' => $buckets, + ], + 'top_posts' => [ + 'reactions' => $this->top($topReactions), + 'comments' => $this->top($topComments), + ], + 'performance' => $this->performance($currentAccounts, $previousAccounts), + ]; + } + + private function publications(Workspace $workspace, CarbonImmutable $start, CarbonImmutable $end): LazyCollection + { + $latest = DB::table('analytics_publication_daily_snapshots as daily') + ->join('analytics_publications as parent', 'parent.id', '=', 'daily.publication_id') + ->where('parent.workspace_id', $workspace->id) + ->whereIn('parent.platform', Platform::analyticsValues()) + ->whereBetween('parent.provider_published_at', [$start->startOfDay(), $end->endOfDay()]) + ->select('daily.publication_id') + ->selectRaw('MAX(daily.date) as latest_date') + ->groupBy('daily.publication_id'); + + return DB::table('analytics_publications as publication') + ->leftJoin((new PostPlatform)->getTable().' as destination', 'destination.id', '=', 'publication.post_platform_id') + ->leftJoinSub($latest, 'latest', 'latest.publication_id', '=', 'publication.id') + ->leftJoin('analytics_publication_daily_snapshots as metric', function ($join): void { + $join->on('metric.publication_id', '=', 'publication.id') + ->on('metric.date', '=', 'latest.latest_date'); + }) + ->where('publication.workspace_id', $workspace->id) + ->whereIn('publication.platform', Platform::analyticsValues()) + ->whereBetween('publication.provider_published_at', [$start->startOfDay(), $end->endOfDay()]) + ->select([ + 'publication.id', 'publication.social_account_key', 'publication.social_account_id', + 'publication.post_platform_id', 'destination.post_id', 'publication.platform', 'publication.network', + 'publication.account_display_name', 'publication.account_username', + 'publication.account_avatar_url', 'publication.remote_id', + 'publication.provider_published_at', 'publication.origin', 'publication.content_type', + 'publication.availability', + 'publication.permalink', 'publication.excerpt', 'publication.preview_metadata', + 'metric.reactions_count', 'metric.comments_count', 'metric.shares_count', + 'metric.saves_count', 'metric.views_count', 'metric.impressions_count', + 'metric.reach_count', 'metric.engagement_count', 'metric.exposure_count', + 'metric.exposure_kind', 'metric.collected_at', + ]) + ->cursor(); + } + + /** @return array{posts: int, reactions: int, comments: int, engagement: int, exposure: int, has_reactions: bool, has_comments: bool} */ + private function emptyTotals(): array + { + return [ + 'posts' => 0, + 'reactions' => 0, + 'comments' => 0, + 'engagement' => 0, + 'exposure' => 0, + 'has_reactions' => false, + 'has_comments' => false, + ]; + } + + /** + * @param array{posts: int, reactions: int, comments: int, engagement: int, exposure: int, has_reactions: bool, has_comments: bool} $totals + * @return array{posts: int, reactions: int, comments: int, engagement: int, exposure: int, has_reactions: bool, has_comments: bool} + */ + private function addTotals(array $totals, object $row): array + { + $totals['posts'] = data_get($totals, 'posts') + 1; + + if ($row->reactions_count !== null) { + $totals['has_reactions'] = true; + $totals['reactions'] = data_get($totals, 'reactions') + (int) $row->reactions_count; + } + + if ($row->comments_count !== null) { + $totals['has_comments'] = true; + $totals['comments'] = data_get($totals, 'comments') + (int) $row->comments_count; + } + + if ($row->engagement_count !== null && $row->exposure_count !== null && (int) $row->exposure_count > 0) { + $totals['engagement'] = data_get($totals, 'engagement') + (int) $row->engagement_count; + $totals['exposure'] = data_get($totals, 'exposure') + (int) $row->exposure_count; + } + + return $totals; + } + + /** + * @param array $totals + * @return array{posts: int, reactions: ?int, comments: ?int, engagement_rate: ?float} + */ + private function finalizeTotals(array $totals): array + { + return [ + 'posts' => data_get($totals, 'posts'), + 'reactions' => data_get($totals, 'has_reactions') ? data_get($totals, 'reactions') : null, + 'comments' => data_get($totals, 'has_comments') ? data_get($totals, 'comments') : null, + 'engagement_rate' => data_get($totals, 'exposure') === 0 ? null : round(data_get($totals, 'engagement') / data_get($totals, 'exposure') * 100, 2), + ]; + } + + /** @param list $rows */ + private function retainTopPublication(array &$rows, object $row, string $metric): void + { + if ($row->{$metric} === null) { + return; + } + + $rows[] = $row; + usort($rows, fn (object $a, object $b): int => ((int) $b->{$metric} <=> (int) $a->{$metric}) + ?: strcmp((string) $b->provider_published_at, (string) $a->provider_published_at) + ?: strcmp((string) $a->id, (string) $b->id)); + + if (count($rows) > 5) { + array_pop($rows); + } + } + + /** @return list> */ + private function top(array $rows): array + { + return array_map(function (object $row): array { + return [ + 'id' => $row->id, + 'post_platform_id' => $row->post_platform_id, + 'post_id' => $row->post_id, + 'social_account_key' => $row->social_account_key, + 'platform' => $row->platform, + 'name' => $row->account_display_name, + 'username' => $row->account_username, + 'origin' => $row->origin, + 'content_type' => $row->content_type, + 'availability' => $row->availability, + 'published_at' => $row->provider_published_at, + 'permalink' => $row->permalink, + 'excerpt' => $row->excerpt, + 'preview_metadata' => $row->preview_metadata ? json_decode((string) $row->preview_metadata, true) : null, + 'reactions' => $row->reactions_count === null ? null : (int) $row->reactions_count, + 'comments' => $row->comments_count === null ? null : (int) $row->comments_count, + ]; + }, $rows); + } + + /** @return list> */ + private function performance(array $current, array $previous): array + { + $rows = []; + + foreach ($current as $key => $account) { + $representative = data_get($account, 'row'); + $totals = $this->finalizeTotals(data_get($account, 'totals')); + $prior = $this->finalizeTotals(data_get($previous, $key, $this->emptyTotals())); + $rows[] = [ + 'social_account_key' => $key, + 'platform' => $representative->platform, + 'name' => $representative->account_display_name, + 'username' => $representative->account_username, + 'avatar_url' => $representative->account_avatar_url, + 'posts' => MetricComparison::between(data_get($totals, 'posts'), data_get($prior, 'posts')), + 'reactions' => MetricComparison::between(data_get($totals, 'reactions'), data_get($prior, 'reactions')), + 'comments' => MetricComparison::between(data_get($totals, 'comments'), data_get($prior, 'comments')), + 'engagement_rate' => MetricComparison::between(data_get($totals, 'engagement_rate'), data_get($prior, 'engagement_rate')), + ]; + } + + usort($rows, fn (array $a, array $b): int => [data_get($a, 'platform'), data_get($a, 'username'), data_get($a, 'social_account_key')] + <=> [data_get($b, 'platform'), data_get($b, 'username'), data_get($b, 'social_account_key')]); + + return $rows; + } + + private function inRange(string $date, DateRange $range): bool + { + $day = substr($date, 0, 10); + + return $day >= $range->start->toDateString() && $day <= $range->end->toDateString(); + } +} diff --git a/app/Actions/Analytics/BuildWorkspaceAnalyticsReport.php b/app/Actions/Analytics/BuildWorkspaceAnalyticsReport.php new file mode 100644 index 000000000..c6db41b10 --- /dev/null +++ b/app/Actions/Analytics/BuildWorkspaceAnalyticsReport.php @@ -0,0 +1,88 @@ + + */ + public function forSelection(Workspace $workspace, array $selected = []): array + { + $bounds = $this->bounds->execute($workspace); + + return $this->execute($workspace, $this->dateRange->execute($bounds, $selected), $bounds); + } + + /** + * @param array{min: ?string, max: ?string}|null $bounds + * @return array + */ + public function execute(Workspace $workspace, DateRange $range, ?array $bounds = null): array + { + $previous = $range->previous(); + $publications = $this->publications->execute($workspace, $previous, $range); + $followers = $this->followers->execute($workspace, $previous, $range); + $current = data_get($publications, 'current_totals'); + $prior = data_get($publications, 'previous_totals'); + $currentFollowers = data_get($followers, 'current_total'); + $previousFollowers = data_get($followers, 'previous_total'); + + return [ + 'bounds' => $bounds ?? $this->bounds->execute($workspace), + 'range' => $range->toArray(), + 'previous_range' => $previous->toArray(), + 'summary' => [ + 'posts' => MetricComparison::between(data_get($current, 'posts'), data_get($prior, 'posts')), + 'followers' => [ + 'value' => $currentFollowers, + 'previous' => $previousFollowers, + 'change' => $currentFollowers !== null && $previousFollowers !== null + ? $currentFollowers - $previousFollowers : null, + ], + 'reactions' => MetricComparison::between(data_get($current, 'reactions'), data_get($prior, 'reactions')), + 'comments' => MetricComparison::between(data_get($current, 'comments'), data_get($prior, 'comments')), + 'engagement_rate' => MetricComparison::between(data_get($current, 'engagement_rate'), data_get($prior, 'engagement_rate')), + ], + 'followers' => data_get($followers, 'followers'), + 'posts' => data_get($publications, 'posts'), + 'top_posts' => data_get($publications, 'top_posts'), + 'performance' => data_get($publications, 'performance'), + 'coverage' => $this->coverage($workspace), + ]; + } + + /** @return list> */ + private function coverage(Workspace $workspace): array + { + return AnalyticsSyncState::query() + ->whereHas('socialAccount', fn (Builder $accounts): Builder => $accounts + ->whereBelongsTo($workspace) + ->connected() + ->active() + ->includedInAnalytics()) + ->select([ + 'social_account_id', 'collector', 'status', + 'target_since', 'oldest_reached_at', 'high_watermark_at', + 'last_success_at', 'last_error_category', + ]) + ->get() + ->toArray(); + } +} diff --git a/app/Actions/Analytics/DispatchAccountAnalytics.php b/app/Actions/Analytics/DispatchAccountAnalytics.php new file mode 100644 index 000000000..baac30d31 --- /dev/null +++ b/app/Actions/Analytics/DispatchAccountAnalytics.php @@ -0,0 +1,43 @@ +connected() + ->active() + ->includedInAnalytics() + ->find($socialAccount->id); + + if (! $currentAccount) { + return; + } + + if ($this->collectors->supports($currentAccount->platform)) { + CollectAccountDailySnapshot::dispatch( + $currentAccount->id, + CarbonImmutable::now('UTC')->toDateString(), + )->afterCommit(); + } + + BootstrapAccountAnalytics::dispatch($currentAccount->id, true)->afterCommit(); + } catch (Throwable $exception) { + report($exception); + } + } +} diff --git a/app/Actions/Analytics/GetAnalyticsBounds.php b/app/Actions/Analytics/GetAnalyticsBounds.php new file mode 100644 index 000000000..8f6bf5ebc --- /dev/null +++ b/app/Actions/Analytics/GetAnalyticsBounds.php @@ -0,0 +1,35 @@ +where('workspace_id', $workspace->id) + ->whereIn('platform', Platform::analyticsValues()) + ->selectRaw('MIN(date) as earliest, MAX(date) as latest') + ->first(); + $publications = DB::table('analytics_publications') + ->where('workspace_id', $workspace->id) + ->whereIn('platform', Platform::analyticsValues()) + ->selectRaw('MIN(provider_published_at) as earliest, MAX(provider_published_at) as latest') + ->first(); + $minimum = array_filter([$accounts?->earliest, $publications?->earliest]); + $maximum = array_filter([$accounts?->latest, $publications?->latest]); + + return [ + 'min' => $minimum ? CarbonImmutable::parse(min($minimum), 'UTC')->toDateString() : null, + 'max' => $maximum ? CarbonImmutable::parse(max($maximum), 'UTC')->toDateString() : null, + ]; + } +} diff --git a/app/Actions/Analytics/QueuePublicationMetricsForPage.php b/app/Actions/Analytics/QueuePublicationMetricsForPage.php new file mode 100644 index 000000000..7636090e4 --- /dev/null +++ b/app/Actions/Analytics/QueuePublicationMetricsForPage.php @@ -0,0 +1,66 @@ + $item->providerPostId, $page->publications); + + if ($providerIds === []) { + return; + } + + AnalyticsPublication::query() + ->available() + ->where('social_account_id', $account->id) + ->whereIn('remote_id', $providerIds) + ->each(function (AnalyticsPublication $publication): void { + $this->queue($publication); + }); + } + + public function queue(AnalyticsPublication $publication): void + { + if (! in_array($publication->platform->value, Platform::analyticsValues(), true)) { + return; + } + + $now = CarbonImmutable::now('UTC'); + $isStory = $publication->content_type === PublicationContentType::Story + && in_array($publication->platform, [Platform::Instagram, Platform::InstagramFacebook], true); + + if ($isStory) { + if ($publication->provider_published_at->addDay()->greaterThan($now)) { + ScheduleInstagramStoryMetrics::dispatch($publication->id)->afterCommit(); + } + + return; + } + + $days = $publication->platform === Platform::X ? 20 : 30; + $recent = $publication->provider_published_at->greaterThanOrEqualTo($now->subDays($days)->startOfDay()); + + if (! $recent && $publication->dailySnapshots()->exists()) { + return; + } + + CollectPublicationMetrics::dispatch( + $publication->id, + $now->toDateString(), + ! $recent, + )->afterCommit(); + } +} diff --git a/app/Actions/Analytics/ReadPublicationAnalytics.php b/app/Actions/Analytics/ReadPublicationAnalytics.php new file mode 100644 index 000000000..dfe77dacc --- /dev/null +++ b/app/Actions/Analytics/ReadPublicationAnalytics.php @@ -0,0 +1,235 @@ + */ + public function latestForWorkspacePublication(Workspace $workspace, string $publicationId): array + { + $publication = AnalyticsPublication::query() + ->available() + ->whereBelongsTo($workspace) + ->whereIn('platform', Platform::analyticsValues()) + ->findOrFail($publicationId); + + return $this->latestForPublication($publication); + } + + /** @return array */ + public function latestForPostPublication(Post $post, ?string $publicationId = null): array + { + $publication = AnalyticsPublication::query() + ->available() + ->where('workspace_id', $post->workspace_id) + ->whereIn('platform', Platform::analyticsValues()) + ->whereHas('postPlatform', fn (Builder $query): Builder => $query->whereBelongsTo($post)) + ->when($publicationId, fn (Builder $query): Builder => $query->whereKey($publicationId)) + ->orderByDesc('provider_published_at') + ->firstOrFail(); + + return $this->latestForPublication($publication); + } + + /** @return Collection> */ + public function forPost(Post $post): Collection + { + $destinations = $post->postPlatforms + ->where('enabled', true) + ->values(); + $details = $this->latestForPost($post, $destinations); + + return $destinations->map(fn (PostPlatform $destination): array => [ + 'post_platform_id' => $destination->id, + 'platform' => $destination->platform->value, + 'status' => $destination->status->value, + 'platform_post_id' => $destination->platform_post_id, + 'platform_url' => $destination->platform_url, + 'metrics' => $this->visibleDetail(data_get($details, $destination->id, $this->unavailable('not_collected'))), + ]); + } + + /** @return array */ + public function forPlatform(PostPlatform $postPlatform): array + { + return $this->visibleDetail($this->latestForPostPlatform($postPlatform)); + } + + /** + * @param Collection $destinations + * @return array> + */ + public function latestForPost(Post $post, Collection $destinations): array + { + $eligible = $destinations->filter(fn (PostPlatform $destination): bool => $this->isCollectable($destination)); + + $publications = $eligible->isEmpty() ? collect() : AnalyticsPublication::query() + ->available() + ->where('workspace_id', $post->workspace_id) + ->whereIn('post_platform_id', $eligible->pluck('id')) + ->whereIn('platform', Platform::analyticsValues()) + ->get() + ->keyBy('post_platform_id'); + + $snapshots = collect(); + + if ($publications->isNotEmpty()) { + $snapshotTable = (new AnalyticsPublicationDailySnapshot)->getTable(); + $latestDates = AnalyticsPublicationDailySnapshot::query() + ->whereIn('publication_id', $publications->pluck('id')) + ->select('publication_id') + ->selectRaw('MAX(date) as latest_date') + ->groupBy('publication_id'); + + $snapshots = AnalyticsPublicationDailySnapshot::query() + ->joinSub($latestDates, 'latest', fn ($join) => $join + ->on("{$snapshotTable}.publication_id", '=', 'latest.publication_id') + ->on("{$snapshotTable}.date", '=', 'latest.latest_date')) + ->select("{$snapshotTable}.*") + ->get() + ->keyBy('publication_id'); + } + + return $destinations->mapWithKeys(function (PostPlatform $destination) use ($publications, $snapshots): array { + if ($destination->status !== Status::Published || ! $destination->platform_post_id) { + return [$destination->id => $this->unavailable('not_published')]; + } + + if (! in_array($destination->platform->value, Platform::analyticsValues(), true)) { + return [$destination->id => $this->unavailable('platform_not_supported')]; + } + + $publication = $publications->get($destination->id); + + return [$destination->id => $publication + ? $this->detail($publication, $snapshots->get($publication->id)) + : $this->unavailable('not_collected')]; + })->all(); + } + + /** @return array */ + public function latestForPostPlatform(PostPlatform $postPlatform): array + { + if ($postPlatform->status !== Status::Published || ! $postPlatform->platform_post_id) { + return $this->unavailable('not_published'); + } + + if (! in_array($postPlatform->platform->value, Platform::analyticsValues(), true)) { + return $this->unavailable('platform_not_supported'); + } + + $publication = AnalyticsPublication::query() + ->available() + ->where('workspace_id', $postPlatform->post->workspace_id) + ->where('post_platform_id', $postPlatform->id) + ->whereIn('platform', Platform::analyticsValues()) + ->first(); + + return $publication ? $this->latestForPublication($publication) : $this->unavailable('not_collected'); + } + + /** @return array */ + public function latestForPublication(AnalyticsPublication $publication): array + { + if (! in_array($publication->platform->value, Platform::analyticsValues(), true)) { + return $this->unavailable('platform_not_supported'); + } + + $snapshot = $publication->dailySnapshots()->orderByDesc('date')->first(); + + return $this->detail($publication, $snapshot); + } + + private function isCollectable(PostPlatform $destination): bool + { + return $destination->status === Status::Published + && filled($destination->platform_post_id) + && in_array($destination->platform->value, Platform::analyticsValues(), true); + } + + /** + * @param array $detail + * @return array + */ + private function visibleDetail(array $detail): array + { + if (! data_get($detail, 'available')) { + return ['unsupported' => true, 'reason' => data_get($detail, 'reason')]; + } + + return $detail; + } + + /** @return array */ + private function detail(AnalyticsPublication $publication, ?AnalyticsPublicationDailySnapshot $snapshot): array + { + return [ + 'available' => true, + 'reason' => null, + 'publication' => [ + 'id' => $publication->id, + 'post_platform_id' => $publication->post_platform_id, + 'social_account_key' => $publication->social_account_key, + 'platform' => $publication->platform->value, + 'origin' => $publication->origin->value, + 'content_type' => $publication->content_type->value, + 'availability' => $publication->availability->value, + 'provider_published_at' => $publication->provider_published_at?->toIso8601String(), + 'permalink' => $publication->permalink, + 'excerpt' => $publication->excerpt, + 'preview_metadata' => $publication->preview_metadata, + 'account_display_name' => $publication->account_display_name, + 'account_username' => $publication->account_username, + 'account_avatar_url' => $publication->account_avatar_url, + ], + 'snapshot' => $snapshot ? $this->snapshot($snapshot) : null, + 'metrics' => $snapshot?->metrics ?? [], + ]; + } + + /** @return array */ + private function snapshot(AnalyticsPublicationDailySnapshot $snapshot): array + { + return [ + 'date' => $snapshot->date->toDateString(), + 'collected_at' => $snapshot->collected_at?->toIso8601String(), + 'provider_observed_at' => $snapshot->provider_observed_at?->toIso8601String(), + 'reactions_count' => $snapshot->reactions_count, + 'comments_count' => $snapshot->comments_count, + 'shares_count' => $snapshot->shares_count, + 'saves_count' => $snapshot->saves_count, + 'views_count' => $snapshot->views_count, + 'impressions_count' => $snapshot->impressions_count, + 'reach_count' => $snapshot->reach_count, + 'engagement_count' => $snapshot->engagement_count, + 'exposure_count' => $snapshot->exposure_count, + 'exposure_kind' => $snapshot->exposure_kind?->value, + 'watch_time_milliseconds' => $snapshot->watch_time_milliseconds, + 'average_watch_time_milliseconds' => $snapshot->average_watch_time_milliseconds, + ]; + } + + /** @return array{available: false, reason: string, publication: null, snapshot: null, metrics: array} */ + private function unavailable(string $reason): array + { + return [ + 'available' => false, + 'reason' => $reason, + 'publication' => null, + 'snapshot' => null, + 'metrics' => [], + ]; + } +} diff --git a/app/Actions/Analytics/ResolveAnalyticsAccountKey.php b/app/Actions/Analytics/ResolveAnalyticsAccountKey.php new file mode 100644 index 000000000..82034b339 --- /dev/null +++ b/app/Actions/Analytics/ResolveAnalyticsAccountKey.php @@ -0,0 +1,37 @@ + $account->workspace_id, + 'network' => $account->platform->network(), + 'platform_user_id' => $account->platform_user_id, + ]; + + $snapshotKey = AnalyticsAccountDailySnapshot::query() + ->where($identity) + ->latest('date') + ->value('social_account_key'); + + if (is_string($snapshotKey)) { + return $snapshotKey; + } + + $publicationKey = AnalyticsPublication::query() + ->where($identity) + ->latest('provider_published_at') + ->value('social_account_key'); + + return is_string($publicationKey) ? $publicationKey : $account->id; + } +} diff --git a/app/Actions/Analytics/ResolveAnalyticsDateRange.php b/app/Actions/Analytics/ResolveAnalyticsDateRange.php new file mode 100644 index 000000000..e01b3d011 --- /dev/null +++ b/app/Actions/Analytics/ResolveAnalyticsDateRange.php @@ -0,0 +1,42 @@ +subDays(29); + + if ($minimumDate !== null && $selectedStart !== null) { + $start = CarbonImmutable::parse($selectedStart, 'UTC'); + } + + if ($maximumDate !== null && $selectedEnd !== null) { + $end = CarbonImmutable::parse($selectedEnd, 'UTC'); + } + + if ($minimumDate !== null && $maximumDate !== null) { + $minimum = CarbonImmutable::parse($minimumDate, 'UTC'); + $maximum = CarbonImmutable::parse($maximumDate, 'UTC'); + $start = $start->lessThan($minimum) ? $minimum : ($start->greaterThan($maximum) ? $maximum : $start); + $end = $end->lessThan($minimum) ? $minimum : ($end->greaterThan($maximum) ? $maximum : $end); + } + + return new DateRange($start->greaterThan($end) ? $end : $start, $end); + } +} diff --git a/app/Actions/Analytics/SyncTryPostPublication.php b/app/Actions/Analytics/SyncTryPostPublication.php new file mode 100644 index 000000000..cc6076c26 --- /dev/null +++ b/app/Actions/Analytics/SyncTryPostPublication.php @@ -0,0 +1,89 @@ +loadMissing(['post', 'socialAccount']); + $account = $postPlatform->socialAccount; + + if (! $account) { + throw new LogicException('A live social account is required to capture publication identity.'); + } + + return $this->fromIdentity( + TryPostPublicationIdentity::fromAccount($account, $this->accountKeys->for($account)), + $postPlatform, + $account, + ); + } + + public function fromIdentity( + TryPostPublicationIdentity $identity, + PostPlatform $postPlatform, + ?SocialAccount $liveAccount = null, + ): AnalyticsPublication { + return $this->publications->tryPost( + $identity, + $postPlatform, + $this->normalizedContentType($postPlatform), + $this->excerpt($postPlatform), + $liveAccount, + ); + } + + private function normalizedContentType(PostPlatform $postPlatform): PublicationContentType + { + $specializedType = match ($postPlatform->content_type) { + ContentType::InstagramReel, ContentType::FacebookReel => PublicationContentType::Reel, + ContentType::InstagramStory, ContentType::FacebookStory => PublicationContentType::Story, + ContentType::YouTubeShort => PublicationContentType::Short, + ContentType::TikTokVideo, ContentType::PinterestVideoPin => PublicationContentType::Video, + ContentType::TikTokPhoto, ContentType::PinterestCarousel => PublicationContentType::Carousel, + default => null, + }; + + if ($specializedType) { + return $specializedType; + } + + $media = $postPlatform->post?->media_items; + + if (! $media || $media->isEmpty()) { + return PublicationContentType::Text; + } + + if ($media->count() > 1) { + return PublicationContentType::Carousel; + } + + return $media->first()->isVideo() + ? PublicationContentType::Video + : PublicationContentType::Image; + } + + private function excerpt(PostPlatform $postPlatform): ?string + { + $content = trim(html_entity_decode(strip_tags((string) $postPlatform->post?->content))); + + return $content === '' ? null : Str::limit($content, 500); + } +} diff --git a/app/Actions/Analytics/UpsertAnalyticsPublication.php b/app/Actions/Analytics/UpsertAnalyticsPublication.php new file mode 100644 index 000000000..a8d04787f --- /dev/null +++ b/app/Actions/Analytics/UpsertAnalyticsPublication.php @@ -0,0 +1,285 @@ +accountKeys->for($account), + ); + + return $this->persist( + identity: $identity, + providerPostId: $publication->providerPostId, + providerPublishedAt: $publication->publishedAt, + origin: PublicationOrigin::External, + contentType: $publication->contentType, + providerContentType: $publication->providerContentType, + permalink: $publication->permalink, + excerpt: $publication->excerpt, + previewMetadata: $publication->previewMetadata, + providerMetadata: $publication->providerMetadata, + providerSyncedAt: now(), + liveAccount: $account, + ); + } + + public function tryPost( + TryPostPublicationIdentity $identity, + PostPlatform $postPlatform, + PublicationContentType $contentType, + ?string $excerpt, + ?SocialAccount $liveAccount = null, + ): AnalyticsPublication { + return $this->persist( + identity: $identity, + providerPostId: (string) $postPlatform->platform_post_id, + providerPublishedAt: ($postPlatform->published_at ?? $postPlatform->updated_at)->toImmutable(), + origin: PublicationOrigin::TryPost, + contentType: $contentType, + providerContentType: $postPlatform->content_type->value, + permalink: $postPlatform->platform_url, + excerpt: $excerpt, + previewMetadata: null, + providerMetadata: null, + postPlatformId: $postPlatform->id, + liveAccount: $liveAccount, + ); + } + + public function reconcileTikTokPublicId(AnalyticsPublication $publication, string $publicId): void + { + DB::transaction(function () use ($publication, $publicId): void { + $current = AnalyticsPublication::query()->lockForUpdate()->findOrFail($publication->id); + + if ($current->remote_id === $publicId || ! $current->post_platform_id) { + return; + } + + $discovered = AnalyticsPublication::query() + ->where('workspace_id', $current->workspace_id) + ->where('social_account_key', $current->social_account_key) + ->where('network', $current->network) + ->where('remote_id', $publicId) + ->lockForUpdate() + ->first(); + + if ($discovered) { + if ($discovered->post_platform_id && $discovered->post_platform_id !== $current->post_platform_id) { + throw new LogicException('TikTok public id is already attached to another TryPost publication.'); + } + + $discovered->dailySnapshots()->lockForUpdate()->reorder()->lazyById(100)->each(function (AnalyticsPublicationDailySnapshot $snapshot) use ($current): void { + $existing = $current->dailySnapshots() + ->whereDate('date', $snapshot->date->toDateString()) + ->lockForUpdate() + ->first(); + + if (! $existing) { + $snapshot->update(['publication_id' => $current->id]); + + return; + } + + if ($snapshot->collected_at->greaterThan($existing->collected_at)) { + $existing->fill($snapshot->only([ + 'collected_at', 'provider_observed_at', 'metrics', + 'reactions_count', 'comments_count', 'shares_count', 'saves_count', + 'views_count', 'impressions_count', 'reach_count', + 'engagement_count', 'exposure_count', 'exposure_kind', + 'watch_time_milliseconds', 'average_watch_time_milliseconds', + ]))->save(); + } + + $snapshot->delete(); + }); + + $current->fill([ + 'permalink' => $discovered->permalink ?? $current->permalink, + 'preview_metadata' => $discovered->preview_metadata ?? $current->preview_metadata, + 'provider_metadata' => $discovered->provider_metadata ?? $current->provider_metadata, + 'provider_synced_at' => $discovered->provider_synced_at ?? $current->provider_synced_at, + ]); + $discovered->delete(); + } + + $current->remote_id = $publicId; + $current->save(); + + PostPlatform::query()->whereKey($current->post_platform_id)->update([ + 'platform_post_id' => $publicId, + 'platform_url' => $current->permalink, + ]); + + $publication->setRawAttributes($current->getAttributes(), true); + }); + } + + /** + * @param array|null $previewMetadata + * @param array|null $providerMetadata + */ + private function persist( + TryPostPublicationIdentity $identity, + string $providerPostId, + \DateTimeInterface $providerPublishedAt, + PublicationOrigin $origin, + PublicationContentType $contentType, + ?string $providerContentType, + ?string $permalink, + ?string $excerpt, + ?array $previewMetadata, + ?array $providerMetadata, + ?string $postPlatformId = null, + ?\DateTimeInterface $providerSyncedAt = null, + ?SocialAccount $liveAccount = null, + ): AnalyticsPublication { + try { + return $this->write( + $identity, + $providerPostId, + $providerPublishedAt, + $origin, + $contentType, + $providerContentType, + $permalink, + $excerpt, + $previewMetadata, + $providerMetadata, + $postPlatformId, + $providerSyncedAt, + $liveAccount, + true, + ); + } catch (UniqueConstraintViolationException) { + return $this->write( + $identity, + $providerPostId, + $providerPublishedAt, + $origin, + $contentType, + $providerContentType, + $permalink, + $excerpt, + $previewMetadata, + $providerMetadata, + $postPlatformId, + $providerSyncedAt, + $liveAccount, + false, + ); + } + } + + /** + * @param array|null $previewMetadata + * @param array|null $providerMetadata + */ + private function write( + TryPostPublicationIdentity $identity, + string $providerPostId, + \DateTimeInterface $providerPublishedAt, + PublicationOrigin $origin, + PublicationContentType $contentType, + ?string $providerContentType, + ?string $permalink, + ?string $excerpt, + ?array $previewMetadata, + ?array $providerMetadata, + ?string $postPlatformId, + ?\DateTimeInterface $providerSyncedAt, + ?SocialAccount $liveAccount, + bool $mayCreate, + ): AnalyticsPublication { + return DB::transaction(function () use ($contentType, $excerpt, $identity, $liveAccount, $mayCreate, $origin, $permalink, $postPlatformId, $previewMetadata, $providerContentType, $providerMetadata, $providerPostId, $providerPublishedAt, $providerSyncedAt): AnalyticsPublication { + $identityFields = [ + 'workspace_id' => $identity->workspaceId, + 'social_account_key' => $identity->socialAccountKey, + 'network' => $identity->network, + 'remote_id' => $providerPostId, + ]; + $publication = AnalyticsPublication::query() + ->where($identityFields) + ->lockForUpdate() + ->first(); + + if (! $publication && $postPlatformId) { + $publication = AnalyticsPublication::query() + ->where('post_platform_id', $postPlatformId) + ->lockForUpdate() + ->first(); + } + + if (! $publication && ! $mayCreate) { + $publication = AnalyticsPublication::query() + ->where($identityFields) + ->lockForUpdate() + ->firstOrFail(); + } + + $now = now(); + $publication ??= new AnalyticsPublication([ + ...$identityFields, + 'first_seen_at' => $now, + ]); + + $values = [ + 'social_account_id' => $liveAccount?->id + ?? SocialAccount::query()->whereKey($identity->socialAccountId)->value('id'), + 'post_platform_id' => $postPlatformId ?? $publication->post_platform_id, + 'platform_user_id' => $identity->platformUserId, + 'platform' => $identity->platform, + 'provider_published_at' => $providerPublishedAt, + 'origin' => $origin === PublicationOrigin::TryPost + ? PublicationOrigin::TryPost + : ($publication->origin ?? PublicationOrigin::External), + 'content_type' => $contentType, + 'availability' => PublicationAvailability::Available, + 'last_seen_at' => $now, + 'provider_synced_at' => $providerSyncedAt ?? $publication->provider_synced_at, + ]; + + foreach ([ + 'provider_content_type' => $providerContentType, + 'permalink' => $permalink, + 'excerpt' => $excerpt, + 'preview_metadata' => $previewMetadata, + 'provider_metadata' => $providerMetadata, + 'account_display_name' => $identity->accountDisplayName, + 'account_username' => $identity->accountUsername, + 'account_avatar_url' => $identity->accountAvatarUrl, + ] as $key => $value) { + if ($value !== null) { + $values[$key] = $value; + } + } + + $publication->fill($values)->save(); + + return $publication->refresh(); + }); + } +} diff --git a/app/Actions/Analytics/WriteAccountDailySnapshot.php b/app/Actions/Analytics/WriteAccountDailySnapshot.php new file mode 100644 index 000000000..726b7a838 --- /dev/null +++ b/app/Actions/Analytics/WriteAccountDailySnapshot.php @@ -0,0 +1,71 @@ +write($account, $observation, true); + } catch (UniqueConstraintViolationException) { + return $this->write($account, $observation, false); + } + } + + private function write( + SocialAccount $account, + AccountDailyObservation $observation, + bool $mayCreate, + ): AnalyticsAccountDailySnapshot { + return DB::transaction(function () use ($account, $observation, $mayCreate): AnalyticsAccountDailySnapshot { + $identity = [ + 'workspace_id' => $account->workspace_id, + 'social_account_key' => $this->accountKeys->for($account), + 'date' => $observation->date->toDateString(), + ]; + $snapshot = AnalyticsAccountDailySnapshot::query()->where($identity)->lockForUpdate()->first(); + + if ($snapshot?->provenance === ObservationProvenance::Actual + && $observation->provenance === ObservationProvenance::CarriedForward) { + return $snapshot; + } + + if (! $snapshot && ! $mayCreate) { + $snapshot = AnalyticsAccountDailySnapshot::query()->where($identity)->lockForUpdate()->firstOrFail(); + } + + $snapshot ??= new AnalyticsAccountDailySnapshot($identity); + $snapshot->fill([ + 'social_account_id' => $account->id, + 'network' => $account->platform->network(), + 'platform_user_id' => $account->platform_user_id, + 'platform' => $account->platform, + 'account_display_name' => $account->display_name, + 'account_username' => $account->username, + 'account_avatar_url' => $account->avatar_url, + 'followers_count' => $observation->followers, + 'metrics' => $observation->metrics ?: null, + 'provenance' => $observation->provenance, + 'precision' => $observation->precision, + 'provider_observed_at' => $observation->providerObservedAt, + 'collected_at' => $observation->collectedAt ?? now(), + ])->save(); + + return $snapshot->refresh(); + }); + } +} diff --git a/app/Actions/Analytics/WritePublicationDailySnapshot.php b/app/Actions/Analytics/WritePublicationDailySnapshot.php new file mode 100644 index 000000000..696f72be5 --- /dev/null +++ b/app/Actions/Analytics/WritePublicationDailySnapshot.php @@ -0,0 +1,154 @@ +write($publication, $observation, true); + } catch (UniqueConstraintViolationException) { + return $this->write($publication, $observation, false); + } + } + + private function write( + AnalyticsPublication $publication, + PublicationMetricObservation $observation, + bool $mayCreate, + ): AnalyticsPublicationDailySnapshot { + return DB::transaction(function () use ($publication, $observation, $mayCreate): AnalyticsPublicationDailySnapshot { + $snapshot = AnalyticsPublicationDailySnapshot::query() + ->where('publication_id', $publication->id) + ->whereDate('date', $observation->date->toDateString()) + ->lockForUpdate() + ->first(); + + if (! $snapshot) { + if (! $mayCreate) { + $snapshot = AnalyticsPublicationDailySnapshot::query() + ->where('publication_id', $publication->id) + ->whereDate('date', $observation->date->toDateString()) + ->lockForUpdate() + ->firstOrFail(); + } else { + $snapshot = new AnalyticsPublicationDailySnapshot([ + 'publication_id' => $publication->id, + 'date' => $observation->date->toDateString(), + ]); + } + } + + $metrics = $this->mergeMetrics($snapshot->metrics ?? [], $observation->metrics); + + $snapshot->fill([ + 'collected_at' => $observation->collectedAt ?? now(), + 'provider_observed_at' => $observation->providerObservedAt ?? $snapshot->provider_observed_at, + 'metrics' => $metrics ?: null, + ...$this->scalarProjections($metrics), + ]); + $snapshot->save(); + + return $snapshot->refresh(); + }); + } + + /** + * @param array> $existing + * @param list $incoming + * @return array> + */ + private function mergeMetrics(array $existing, array $incoming): array + { + foreach ($incoming as $metric) { + $key = $metric->key->value; + $current = data_get($existing, $key); + $currentIsMeasured = data_get($current, 'availability') === MetricAvailability::Available->value + && is_numeric(data_get($current, 'value')); + $incomingIsMeasured = $metric->availability === MetricAvailability::Available + && $metric->value !== null; + + if ($incomingIsMeasured || ! $currentIsMeasured) { + $existing[$key] = $metric->toArray(); + } + } + + return $existing; + } + + /** + * @param array> $metrics + * @return array + */ + private function scalarProjections(array $metrics): array + { + [$exposureCount, $exposureKind] = $this->exposure($metrics); + + return [ + 'reactions_count' => $this->measuredInteger($metrics, MetricKey::Reactions), + 'comments_count' => $this->measuredInteger($metrics, MetricKey::Comments), + 'shares_count' => $this->measuredInteger($metrics, MetricKey::Shares), + 'saves_count' => $this->measuredInteger($metrics, MetricKey::Saves), + 'views_count' => $this->measuredInteger($metrics, MetricKey::Views), + 'impressions_count' => $this->measuredInteger($metrics, MetricKey::Impressions), + 'reach_count' => $this->measuredInteger($metrics, MetricKey::Reach), + 'engagement_count' => $this->measuredInteger($metrics, MetricKey::Engagements), + 'exposure_count' => $exposureCount, + 'exposure_kind' => $exposureKind, + 'watch_time_milliseconds' => $this->measuredInteger($metrics, MetricKey::WatchTimeMilliseconds), + 'average_watch_time_milliseconds' => $this->measuredInteger($metrics, MetricKey::AverageWatchTimeMilliseconds), + ]; + } + + /** + * @param array> $metrics + * @return array{int|null, string|null} + */ + private function exposure(array $metrics): array + { + foreach ([ + MetricKey::Reach->value => ExposureKind::Reach, + MetricKey::Impressions->value => ExposureKind::Impressions, + MetricKey::Views->value => ExposureKind::Views, + ] as $key => $kind) { + $value = $this->measuredInteger($metrics, MetricKey::from($key)); + + if ($value !== null) { + return [$value, $kind->value]; + } + } + + return [null, null]; + } + + /** @param array> $metrics */ + private function measuredInteger(array $metrics, MetricKey $key): ?int + { + $metric = data_get($metrics, $key->value); + $value = data_get($metric, 'value'); + + if ( + data_get($metric, 'availability') !== MetricAvailability::Available->value + || ! is_numeric($value) + ) { + return null; + } + + return (int) $value; + } +} diff --git a/app/Actions/Media/StoreSignedUpload.php b/app/Actions/Media/StoreSignedUpload.php new file mode 100644 index 000000000..041d1f520 --- /dev/null +++ b/app/Actions/Media/StoreSignedUpload.php @@ -0,0 +1,64 @@ +timestamp + self::CACHE_TTL_BUFFER_SECONDS, + ); + + $cacheKey = self::CLAIM_CACHE_PREFIX.$token; + + if (! Cache::add($cacheKey, true, $ttl)) { + abort(Response::HTTP_CONFLICT); + } + + if (Media::query()->where('upload_token', $token)->exists()) { + abort(Response::HTTP_CONFLICT); + } + + try { + $workspace = Workspace::query()->findOrFail($workspaceId); + $path = $file->getRealPath(); + + if ($path === false) { + abort(Response::HTTP_UNPROCESSABLE_ENTITY, 'Unable to read uploaded file.'); + } + + return DB::transaction(function () use ($workspace, $file, $path, $token): Media { + $media = $workspace->addMediaFromPath( + $path, + $file->getClientOriginalName(), + 'assets', + mimeType: (string) $file->getMimeType(), + ); + $media->upload_token = $token; + $media->save(); + + return $media; + }); + } catch (Throwable $exception) { + Cache::forget($cacheKey); + + throw $exception; + } + } +} diff --git a/app/Actions/SocialAccount/StoreTelegramReactions.php b/app/Actions/SocialAccount/StoreTelegramReactions.php index 2b2f1be5b..050a5d9b5 100644 --- a/app/Actions/SocialAccount/StoreTelegramReactions.php +++ b/app/Actions/SocialAccount/StoreTelegramReactions.php @@ -6,13 +6,12 @@ use App\Enums\SocialAccount\Platform; use App\Models\PostPlatform; -use Illuminate\Support\Facades\Cache; class StoreTelegramReactions { /** * Persist the reaction counts pushed by a `message_reaction_count` update - * onto the matching published post, so they surface as post metrics. + * onto the matching published post platform. * * @param array $update The `message_reaction_count` payload. */ @@ -43,7 +42,5 @@ public static function execute(array $update): void ], is_array($rawReactions) ? $rawReactions : [])); $postPlatform->update(['meta' => [...$postPlatform->meta ?? [], 'reactions' => $reactions]]); - - Cache::forget("post_metrics:{$postPlatform->id}"); } } diff --git a/app/Console/Commands/Analytics/BackfillExistingAnalytics.php b/app/Console/Commands/Analytics/BackfillExistingAnalytics.php new file mode 100644 index 000000000..80c244c5c --- /dev/null +++ b/app/Console/Commands/Analytics/BackfillExistingAnalytics.php @@ -0,0 +1,97 @@ +option('workspace'); + $includeUnsubscribed = (bool) $this->option('include-unsubscribed'); + $staleBefore = CarbonImmutable::now('UTC')->subHours(2); + $date = CarbonImmutable::now('UTC')->toDateString(); + $orphaned = 0; + + Workspace::query() + ->with('account.subscriptions') + ->when($workspaceId, fn (Builder $query): Builder => $query->whereKey($workspaceId)) + ->reorder() + ->lazyById(100) + ->filter(fn (Workspace $workspace): bool => $includeUnsubscribed || $workspace->account->subscribed(Account::SUBSCRIPTION_NAME)) + ->chunk(100) + ->each(function ($workspaces) use ($date, $staleBefore, &$orphaned): void { + $workspaceIds = $workspaces->pluck('id')->all(); + $inWorkspaces = fn (Builder $post): Builder => $post->whereIn('workspace_id', $workspaceIds); + + PostPlatform::query() + ->published() + ->includedInAnalytics() + ->whereNotNull('social_account_id') + ->whereDoesntHave('analyticsPublication') + ->whereHas('post', $inWorkspaces) + ->lazyById(100) + ->chunk(100) + ->each(fn ($chunk) => BackfillTryPostPublications::dispatch( + $chunk->pluck('id')->map(fn ($id): string => (string) $id)->values()->all(), + )); + + $orphaned += PostPlatform::query() + ->published() + ->includedInAnalytics() + ->whereNull('social_account_id') + ->whereHas('post', $inWorkspaces) + ->count(); + + SocialAccount::query() + ->connected() + ->active() + ->includedInAnalytics() + ->whereIn('workspace_id', $workspaceIds) + ->where(function (Builder $query) use ($staleBefore): void { + $query->whereDoesntHave('analyticsSyncStates', fn (Builder $states): Builder => $states->forCollector(SyncCollector::PublicationBackfill)) + ->orWhereHas('analyticsSyncStates', fn (Builder $states): Builder => $states + ->forCollector(SyncCollector::PublicationBackfill) + ->where(function (Builder $state): void { + $state->whereIn('status', [SyncStatus::Pending, SyncStatus::Running]) + ->orWhere(fn (Builder $failed): Builder => $failed + ->where('status', SyncStatus::Failed) + ->where('last_error_category', 'queue_failed')); + }) + ->where('updated_at', '<', $staleBefore)); + }) + ->with(['analyticsSyncStates' => fn ($query) => $query->forCollector(SyncCollector::PublicationBackfill)]) + ->reorder() + ->lazyById(100) + ->each(function (SocialAccount $account) use ($date): void { + if ($account->analyticsSyncStates->isEmpty()) { + CollectAccountDailySnapshot::dispatch($account->id, $date); + } + + BootstrapAccountAnalytics::dispatch($account->id); + }); + }); + + $this->info("historical_identity_unrecoverable={$orphaned}"); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Analytics/DispatchAccountDailyAnalytics.php b/app/Console/Commands/Analytics/DispatchAccountDailyAnalytics.php new file mode 100644 index 000000000..261dd6704 --- /dev/null +++ b/app/Console/Commands/Analytics/DispatchAccountDailyAnalytics.php @@ -0,0 +1,36 @@ +toDateString(); + + SocialAccount::query() + ->connected() + ->active() + ->reorder() + ->lazyById(200) + ->each(function (SocialAccount $account) use ($collectors, $date): void { + if ($collectors->supports($account->platform)) { + CollectAccountDailySnapshot::dispatch($account->id, $date); + } + }); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Analytics/DispatchPublicationDiscovery.php b/app/Console/Commands/Analytics/DispatchPublicationDiscovery.php new file mode 100644 index 000000000..6a2caf155 --- /dev/null +++ b/app/Console/Commands/Analytics/DispatchPublicationDiscovery.php @@ -0,0 +1,67 @@ +subHours(2); + + SocialAccount::query() + ->connected() + ->active() + ->includedInAnalytics() + ->with('analyticsSyncStates') + ->reorder() + ->lazyById(100) + ->each(function (SocialAccount $account) use ($staleBefore): void { + $backfill = $account->analyticsSyncStates + ->first(fn ($state): bool => $state->collector === SyncCollector::PublicationBackfill); + + if ($backfill?->status === SyncStatus::Failed) { + if ($backfill->last_error_category === 'queue_failed') { + BootstrapAccountAnalytics::dispatch($account->id); + } + + return; + } + + $staleBackfill = $backfill + && ($backfill->status === SyncStatus::Pending + || ($backfill->status === SyncStatus::Running && $backfill->last_error_category === null)) + && $backfill->updated_at?->lessThan($staleBefore); + + if ($staleBackfill) { + BootstrapAccountAnalytics::dispatch($account->id); + + return; + } + + $backfillIsTerminal = $account->analyticsSyncStates + ->contains(fn ($state): bool => $state->collector === SyncCollector::PublicationBackfill && $state->isTerminal()); + $discovery = $account->analyticsSyncStates + ->first(fn ($state): bool => $state->collector === SyncCollector::PublicationDiscovery); + + if ($backfillIsTerminal && $discovery) { + DiscoverAccountPublications::dispatch($account->id, $discovery->id); + } + }); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Analytics/DispatchPublicationMetrics.php b/app/Console/Commands/Analytics/DispatchPublicationMetrics.php new file mode 100644 index 000000000..03d134f1b --- /dev/null +++ b/app/Console/Commands/Analytics/DispatchPublicationMetrics.php @@ -0,0 +1,55 @@ +connected() + ->active() + ->includedInAnalytics() + ->reorder() + ->lazyById(100) + ->each(function (SocialAccount $account) use ($now): void { + $days = $account->platform === Platform::X ? 20 : 30; + + AnalyticsPublication::query() + ->available() + ->where('social_account_id', $account->id) + ->where(function ($query) use ($days, $now): void { + $query->where('provider_published_at', '>=', $now->subDays($days)->startOfDay()) + ->orWhereDoesntHave('dailySnapshots'); + }) + ->where(function ($query) use ($now): void { + $query->where('content_type', '!=', PublicationContentType::Story) + ->orWhere('provider_published_at', '>=', $now->subDay()); + }) + ->lazyById(100) + ->each(fn (AnalyticsPublication $publication) => CollectPublicationMetrics::dispatch( + $publication->id, + $now->toDateString(), + $publication->provider_published_at->lessThan($now->subDays($days)->startOfDay()), + )); + }); + + return self::SUCCESS; + } +} diff --git a/app/Dto/Analytics/AccountDailyObservation.php b/app/Dto/Analytics/AccountDailyObservation.php new file mode 100644 index 000000000..9cf7fa4ec --- /dev/null +++ b/app/Dto/Analytics/AccountDailyObservation.php @@ -0,0 +1,22 @@ +start = $start->utc()->startOfDay(); + $this->end = $end->utc()->startOfDay(); + + if ($this->start->greaterThan($this->end)) { + throw new InvalidArgumentException('The analytics date range must start before it ends.'); + } + } + + public function days(): int + { + return (int) $this->start->diffInDays($this->end->addDay()); + } + + public function previous(): self + { + $end = $this->start->subDay(); + + return new self($this->start->subDays($this->days()), $end); + } + + /** @return array{start: string, end: string} */ + public function toArray(): array + { + return ['start' => $this->start->toDateString(), 'end' => $this->end->toDateString()]; + } +} diff --git a/app/Dto/Analytics/DiscoveredPublication.php b/app/Dto/Analytics/DiscoveredPublication.php new file mode 100644 index 000000000..88ccdb701 --- /dev/null +++ b/app/Dto/Analytics/DiscoveredPublication.php @@ -0,0 +1,26 @@ +|null $previewMetadata + * @param array|null $providerMetadata + */ + public function __construct( + public string $providerPostId, + public CarbonImmutable $publishedAt, + public PublicationContentType $contentType, + public ?string $providerContentType = null, + public ?string $permalink = null, + public ?string $excerpt = null, + public ?array $previewMetadata = null, + public ?array $providerMetadata = null, + ) {} +} diff --git a/app/Dto/Analytics/MetricValue.php b/app/Dto/Analytics/MetricValue.php new file mode 100644 index 000000000..4ccdb5d5b --- /dev/null +++ b/app/Dto/Analytics/MetricValue.php @@ -0,0 +1,53 @@ + $this->value, + 'unit' => $this->unit->value, + 'time_basis' => $this->timeBasis->value, + 'precision' => $this->precision->value, + 'availability' => $this->availability->value, + 'provider_metric' => $this->providerMetric, + 'period_start' => $this->periodStart?->toIso8601String(), + 'period_end' => $this->periodEnd?->toIso8601String(), + ]; + } +} diff --git a/app/Dto/Analytics/PublicationMetricObservation.php b/app/Dto/Analytics/PublicationMetricObservation.php new file mode 100644 index 000000000..7975c3bc9 --- /dev/null +++ b/app/Dto/Analytics/PublicationMetricObservation.php @@ -0,0 +1,20 @@ + $metrics + */ + public function __construct( + public CarbonImmutable $date, + public array $metrics, + public ?CarbonImmutable $providerObservedAt = null, + public ?CarbonImmutable $collectedAt = null, + ) {} +} diff --git a/app/Dto/Analytics/PublicationPage.php b/app/Dto/Analytics/PublicationPage.php new file mode 100644 index 000000000..3c35b84f9 --- /dev/null +++ b/app/Dto/Analytics/PublicationPage.php @@ -0,0 +1,20 @@ + $publications + */ + public function __construct( + public array $publications, + public ?string $nextCursor, + public bool $providerExhausted, + public bool $providerLimited = false, + public ?string $partialReason = null, + public bool $canStopAtTarget = true, + ) {} +} diff --git a/app/Dto/Analytics/TryPostPublicationIdentity.php b/app/Dto/Analytics/TryPostPublicationIdentity.php new file mode 100644 index 000000000..c0e5f68d1 --- /dev/null +++ b/app/Dto/Analytics/TryPostPublicationIdentity.php @@ -0,0 +1,38 @@ +workspace_id, + socialAccountId: $account->id, + socialAccountKey: $accountKey, + network: $account->platform->network(), + platformUserId: $account->platform_user_id, + platform: $account->platform, + accountDisplayName: $account->display_name, + accountUsername: $account->username, + accountAvatarUrl: $account->avatar_url, + ); + } +} diff --git a/app/Enums/Analytics/ExposureKind.php b/app/Enums/Analytics/ExposureKind.php new file mode 100644 index 000000000..250a616fc --- /dev/null +++ b/app/Enums/Analytics/ExposureKind.php @@ -0,0 +1,12 @@ + false, + default => true, + }; + } + + /** @return list */ + public static function analyticsValues(): array + { + return array_values(array_map( + fn (self $platform): string => $platform->value, + array_filter(self::cases(), fn (self $platform): bool => $platform->isIncludedInAnalytics()), + )); + } + /** * @return array */ diff --git a/app/Exceptions/Analytics/AnalyticsCollectionException.php b/app/Exceptions/Analytics/AnalyticsCollectionException.php new file mode 100644 index 000000000..b14b9cccd --- /dev/null +++ b/app/Exceptions/Analytics/AnalyticsCollectionException.php @@ -0,0 +1,50 @@ +json(), 'error.errors.0.reason', ''); + $category = match (true) { + $response->status() === 429, + in_array($reason, ['quotaExceeded', 'rateLimitExceeded', 'userRateLimitExceeded'], true) => 'rate_limited', + $response->status() === 401 => 'authentication', + $response->status() === 403 => 'permission', + $response->serverError() => 'transient', + default => 'malformed', + }; + + return new self( + $category, + "{$operation} failed with HTTP {$response->status()}", + RetryAfter::from($response), + ); + } +} diff --git a/app/Http/Controllers/Api/AnalyticsController.php b/app/Http/Controllers/Api/AnalyticsController.php new file mode 100644 index 000000000..d6f4b03d2 --- /dev/null +++ b/app/Http/Controllers/Api/AnalyticsController.php @@ -0,0 +1,30 @@ +user()->currentWorkspace; + $this->authorize('view', $workspace); + + return response()->json($analytics->forSelection($workspace, $request->validated())); + } + + public function showPublication(Request $request, string $publication, ReadPublicationAnalytics $analytics): JsonResponse + { + $workspace = $request->user()->currentWorkspace; + $this->authorize('view', $workspace); + + return response()->json($analytics->latestForWorkspacePublication($workspace, $publication)); + } +} diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index 00e2720d7..eefdc2248 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -4,76 +4,24 @@ namespace App\Http\Controllers\Api; +use App\Actions\Media\StoreSignedUpload; use App\Http\Controllers\Controller; use App\Http\Requests\Api\StoreUploadRequest; use App\Http\Resources\Api\MediaUploadResource; -use App\Models\Media; -use App\Models\Workspace; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\Response; -use Throwable; class UploadController extends Controller { - private const CACHE_TTL_BUFFER_SECONDS = 60; - - /** - * One-shot claim for a signed upload token (api.uploads.store). - * Survives across MCP and any other client that POSTs the signed URL. - */ - private const CLAIM_CACHE_PREFIX = 'media:signed-upload:'; - - public function store(StoreUploadRequest $request, string $token): JsonResponse + public function store(StoreUploadRequest $request, string $token, StoreSignedUpload $upload): JsonResponse { - $expiresAt = (int) $request->query('expires'); - $ttl = max( - self::CACHE_TTL_BUFFER_SECONDS, - $expiresAt - now()->timestamp + self::CACHE_TTL_BUFFER_SECONDS, + $media = $upload->handle( + (string) $request->query('workspace_id'), + $request->file('media'), + $token, + (int) $request->query('expires'), ); - $cacheKey = self::CLAIM_CACHE_PREFIX.$token; - - if (! Cache::add($cacheKey, true, $ttl)) { - abort(Response::HTTP_CONFLICT); - } - - if (Media::where('upload_token', $token)->exists()) { - abort(Response::HTTP_CONFLICT); - } - - try { - $workspace = Workspace::findOrFail((string) $request->query('workspace_id')); - $file = $request->file('media'); - $path = $file->getRealPath(); - - // Stream from PHP's temp upload path — do not load the whole file into - // memory (addMedia() uses file_get_contents; videos can be up to 1GB). - if ($path === false) { - abort(Response::HTTP_UNPROCESSABLE_ENTITY, 'Unable to read uploaded file.'); - } - - $media = DB::transaction(function () use ($workspace, $file, $path, $token): Media { - $media = $workspace->addMediaFromPath( - $path, - $file->getClientOriginalName(), - 'assets', - mimeType: (string) $file->getMimeType(), - ); - $media->upload_token = $token; - $media->save(); - - return $media; - }); - } catch (Throwable $e) { - // Claim is only permanent after Media is stored — release so the - // signed URL can be retried after a transient disk/storage failure. - Cache::forget($cacheKey); - - throw $e; - } - return MediaUploadResource::make($media) ->response() ->setStatusCode(Response::HTTP_CREATED); diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index a764e2796..709266729 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -4,133 +4,38 @@ namespace App\Http\Controllers\App; -use App\Enums\SocialAccount\Platform; -use App\Exceptions\PlatformUnavailableException; +use App\Actions\Analytics\BuildWorkspaceAnalyticsReport; +use App\Actions\Analytics\ReadPublicationAnalytics; use App\Http\Controllers\Controller; -use App\Models\SocialAccount; -use App\Services\Social\FacebookAnalytics; -use App\Services\Social\GoogleBusinessAnalytics; -use App\Services\Social\InstagramAnalytics; -use App\Services\Social\LinkedInPageAnalytics; -use App\Services\Social\PinterestAnalytics; -use App\Services\Social\Telegram\TelegramAnalytics; -use App\Services\Social\ThreadsAnalytics; -use App\Services\Social\TikTokAnalytics; -use App\Services\Social\XAnalytics; -use App\Services\Social\YouTubeAnalytics; -use Illuminate\Http\Client\ConnectionException; -use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; -use Illuminate\Support\Carbon; +use App\Http\Requests\AnalyticsReportRequest; +use App\Http\Requests\App\Analytics\ShowAnalyticsRequest; +use App\Models\Post; use Inertia\Inertia; use Inertia\Response; -use Symfony\Component\HttpFoundation\Response as HttpResponse; class AnalyticsController extends Controller { - private const SUPPORTED_PLATFORMS = [ - Platform::TikTok, - Platform::Instagram, - Platform::InstagramFacebook, - Platform::Threads, - Platform::Facebook, - Platform::X, - Platform::LinkedInPage, - Platform::Pinterest, - Platform::YouTube, - Platform::Telegram, - Platform::GoogleBusiness, - ]; - - public function index(Request $request): Response + public function show(ShowAnalyticsRequest $request, string $post, ReadPublicationAnalytics $analytics): Response { $workspace = $request->user()->currentWorkspace; - $this->authorize('view', $workspace); - $accounts = $workspace->socialAccounts() - ->where('is_active', true) - ->whereIn('platform', self::SUPPORTED_PLATFORMS) - ->get() - ->map(fn (SocialAccount $account) => [ - 'id' => $account->id, - 'platform' => $account->platform->value, - 'username' => $account->username, - 'display_label' => $account->display_label, - 'avatar_url' => $account->avatar_url, - ]); + $record = Post::query()->whereBelongsTo($workspace)->findOrFail($post); - return Inertia::render('analytics/Index', [ - 'accounts' => $accounts, + return Inertia::render('analytics/Publications/Show', [ + 'detail' => $analytics->latestForPostPublication($record, $request->validated('publication')), ]); } - public function show(Request $request, SocialAccount $account): JsonResponse - { + public function index( + AnalyticsReportRequest $request, + BuildWorkspaceAnalyticsReport $analytics, + ): Response { $workspace = $request->user()->currentWorkspace; + $this->authorize('view', $workspace); - if ($account->workspace_id !== $workspace->id) { - abort(HttpResponse::HTTP_FORBIDDEN); - } - - $since = $request->has('since') ? Carbon::parse($request->input('since')) : null; - $until = $request->has('until') ? Carbon::parse($request->input('until')) : null; - - $metrics = $this->metricsFor($account, $since, $until); - - // Google aggregates search keywords by month, so they cannot be folded - // into the daily metric cards and travel as their own list. - if ($account->platform === Platform::GoogleBusiness) { - return response()->json([ - 'metrics' => $metrics, - 'keywords' => $this->searchKeywordsFor($account, $since, $until), - ]); - } - - return response()->json(['metrics' => $metrics]); - } - - /** - * @return array - */ - private function searchKeywordsFor(SocialAccount $account, ?Carbon $since, ?Carbon $until): array - { - try { - return app(GoogleBusinessAnalytics::class)->getSearchKeywords($account, $since, $until); - } catch (PlatformUnavailableException|ConnectionException $e) { - report($e); - - return []; - } - } - - /** - * An unreachable platform is not a server error — empty numbers beat a 500 - * on a page the user just opened. Narrow on purpose: catching Throwable - * would render a defect as "this account has no activity". - * - * @return array - */ - private function metricsFor(SocialAccount $account, ?Carbon $since, ?Carbon $until): array - { - try { - return match ($account->platform) { - Platform::TikTok => app(TikTokAnalytics::class)->getMetrics($account), - Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->getMetrics($account, $since, $until), - Platform::Threads => app(ThreadsAnalytics::class)->getMetrics($account, $since, $until), - Platform::Facebook => app(FacebookAnalytics::class)->getMetrics($account, $since, $until), - Platform::X => app(XAnalytics::class)->getMetrics($account, $since, $until), - Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->getMetrics($account, $since, $until), - Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until), - Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until), - Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account), - Platform::GoogleBusiness => app(GoogleBusinessAnalytics::class)->getMetrics($account, $since, $until), - default => [], - }; - } catch (PlatformUnavailableException|ConnectionException $e) { - report($e); - - return []; - } + return Inertia::render('analytics/Index', [ + 'report' => $analytics->forSelection($workspace, $request->validated()), + ]); } } diff --git a/app/Http/Controllers/App/AnalyticsPublicationController.php b/app/Http/Controllers/App/AnalyticsPublicationController.php new file mode 100644 index 000000000..833dd339e --- /dev/null +++ b/app/Http/Controllers/App/AnalyticsPublicationController.php @@ -0,0 +1,24 @@ +user()->currentWorkspace; + $this->authorize('view', $workspace); + + return Inertia::render('analytics/Publications/Show', [ + 'detail' => $analytics->latestForWorkspacePublication($workspace, $publication), + ]); + } +} diff --git a/app/Http/Controllers/App/PostAiReviewController.php b/app/Http/Controllers/App/PostAiReviewController.php index 2d83a1104..93d12fa48 100644 --- a/app/Http/Controllers/App/PostAiReviewController.php +++ b/app/Http/Controllers/App/PostAiReviewController.php @@ -4,10 +4,10 @@ namespace App\Http\Controllers\App; +use App\Actions\Ai\RecordAiUsage; use App\Ai\Agents\PostContentReviewer; use App\Http\Requests\App\Ai\ReviewPostContentRequest; use App\Models\Post; -use App\Services\Ai\RecordAiUsage; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Gate; use Symfony\Component\HttpFoundation\Response; diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 9b0702eb8..d9ba3c254 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\App; +use App\Actions\Analytics\ReadPublicationAnalytics; use App\Actions\Post\CreatePost; use App\Actions\Post\DeletePost; use App\Actions\Post\DuplicatePost; @@ -23,7 +24,6 @@ use App\Http\Resources\App\SocialAccountResource; use App\Models\Post; use App\Models\PostPlatform; -use App\Services\Post\PostMetricsFetcher; use App\Services\Social\TikTokCreatorInfo; use App\Support\LinkTlds; use App\Support\PostStatusRules; @@ -205,7 +205,7 @@ public function platformMetrics(Request $request, Post $post, PostPlatform $post abort(404); } - return response()->json(app(PostMetricsFetcher::class)->forPlatform($postPlatform)); + return response()->json(app(ReadPublicationAnalytics::class)->forPlatform($postPlatform)); } public function show(Request $request, Post $post): Response|RedirectResponse @@ -227,6 +227,9 @@ public function show(Request $request, Post $post): Response|RedirectResponse return Inertia::render('posts/Show', [ 'workspace' => $workspace, 'post' => (new PostResource($post))->resolve(), + 'postMetrics' => app(ReadPublicationAnalytics::class)->forPost($post) + ->mapWithKeys(fn (array $row): array => [$row['post_platform_id'] => $row['metrics']]) + ->all(), ]); } diff --git a/app/Http/Controllers/Auth/MastodonController.php b/app/Http/Controllers/Auth/MastodonController.php index f31550200..91da1de30 100644 --- a/app/Http/Controllers/Auth/MastodonController.php +++ b/app/Http/Controllers/Auth/MastodonController.php @@ -20,7 +20,7 @@ class MastodonController extends SocialController { protected SocialPlatform $platform = SocialPlatform::Mastodon; - private const SCOPES = 'read:accounts write:statuses write:media'; + private const SCOPES = 'read:accounts read:statuses write:statuses write:media'; /** * Show form to enter Mastodon instance URL diff --git a/app/Http/Requests/AnalyticsReportRequest.php b/app/Http/Requests/AnalyticsReportRequest.php new file mode 100644 index 000000000..bb43ee2ec --- /dev/null +++ b/app/Http/Requests/AnalyticsReportRequest.php @@ -0,0 +1,23 @@ + ['sometimes', 'required', 'date_format:Y-m-d'], + 'end' => ['sometimes', 'required', 'date_format:Y-m-d', 'after_or_equal:start'], + ]; + } +} diff --git a/app/Http/Requests/App/Analytics/ShowAnalyticsRequest.php b/app/Http/Requests/App/Analytics/ShowAnalyticsRequest.php new file mode 100644 index 000000000..67a31cc08 --- /dev/null +++ b/app/Http/Requests/App/Analytics/ShowAnalyticsRequest.php @@ -0,0 +1,22 @@ + ['sometimes', 'required', 'uuid'], + ]; + } +} diff --git a/app/Http/Resources/Api/PostMetricsResource.php b/app/Http/Resources/Api/PostMetricsResource.php index 6724a00fc..e2fa6abd2 100644 --- a/app/Http/Resources/Api/PostMetricsResource.php +++ b/app/Http/Resources/Api/PostMetricsResource.php @@ -4,13 +4,12 @@ namespace App\Http\Resources\Api; -use App\Services\Post\PostMetricsFetcher; +use App\Actions\Analytics\ReadPublicationAnalytics; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * Wraps a Post with its per-platform engagement metrics. The actual fetching - * (with cache + per-platform dispatch) is delegated to PostMetricsFetcher. + * Wraps a Post with its persisted per-platform engagement metrics. */ class PostMetricsResource extends JsonResource { @@ -21,7 +20,7 @@ public function toArray(Request $request): array { return [ 'post_id' => $this->id, - 'platforms' => app(PostMetricsFetcher::class)->forPost($this->resource)->all(), + 'platforms' => app(ReadPublicationAnalytics::class)->forPost($this->resource)->all(), ]; } } diff --git a/app/Jobs/Ai/RegeneratePostMediaImage.php b/app/Jobs/Ai/RegeneratePostMediaImage.php index fd3502e45..b962b1b8c 100644 --- a/app/Jobs/Ai/RegeneratePostMediaImage.php +++ b/app/Jobs/Ai/RegeneratePostMediaImage.php @@ -4,6 +4,7 @@ namespace App\Jobs\Ai; +use App\Actions\Ai\RecordAiUsage; use App\Ai\Agents\PostImageRegenerator; use App\Enums\Media\Source; use App\Enums\Media\Type as MediaType; @@ -12,7 +13,6 @@ use App\Models\Post; use App\Models\SocialAccount; use App\Models\Workspace; -use App\Services\Ai\RecordAiUsage; use App\Services\Image\TemplateImageGenerator; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; diff --git a/app/Jobs/Ai/StreamPostContent.php b/app/Jobs/Ai/StreamPostContent.php index 5bbb379c5..16eea31e7 100644 --- a/app/Jobs/Ai/StreamPostContent.php +++ b/app/Jobs/Ai/StreamPostContent.php @@ -4,9 +4,9 @@ namespace App\Jobs\Ai; +use App\Actions\Ai\RecordAiUsage; use App\Ai\Agents\PostContentStreamer; use App\Models\Workspace; -use App\Services\Ai\RecordAiUsage; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index eeeba767b..f634f78be 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -4,6 +4,7 @@ namespace App\Jobs\Ai; +use App\Actions\Ai\RecordAiUsage; use App\Actions\Post\CreatePost; use App\Ai\Agents\PostContentGenerator; use App\Ai\Agents\PostContentHumanizer; @@ -22,7 +23,6 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; -use App\Services\Ai\RecordAiUsage; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; diff --git a/app/Jobs/Analytics/AbstractPublicationSync.php b/app/Jobs/Analytics/AbstractPublicationSync.php new file mode 100644 index 000000000..3c974652c --- /dev/null +++ b/app/Jobs/Analytics/AbstractPublicationSync.php @@ -0,0 +1,160 @@ +onQueue('analytics'); + } + + abstract protected function collector(): SyncCollector; + + /** @return list */ + public function middleware(): array + { + return [ + new RateLimited('analytics-publications'), + (new WithoutOverlapping("analytics-{$this->collector()->value}:{$this->socialAccountId}")) + ->releaseAfter(300) + ->expireAfter($this->timeout + 30), + ]; + } + + public function providerRateLimitKey(): string + { + $account = SocialAccount::query()->find($this->socialAccountId); + + return $account ? $account->platform->network() : 'missing'; + } + + /** @return list */ + public function backoff(): array + { + return [300, 3600, 7200, 10800, 14400]; + } + + public function handle( + AdvanceAnalyticsSyncState $sync, + PublicationHistoryCollectorFactory $collectors, + QueuePublicationMetricsForPage $metrics, + AnalyticsJobLog $log, + ): void { + $account = SocialAccount::query() + ->connected() + ->active() + ->includedInAnalytics() + ->find($this->socialAccountId); + + if (! $account || ! $this->mayStart($account)) { + return; + } + + $capture = $sync->begin( + $this->syncStateId, + restartTerminal: $this->restartTerminal(), + socialAccountId: $account->id, + ); + + if (! $capture) { + return; + } + + $collector = $this->collector()->value; + $cursorLabel = $capture['cursor'] === null ? 'cursor:start' : 'cursor:'.hash('sha256', $capture['cursor']); + $log->record($account, $collector, $cursorLabel, $this->attempts(), 'started'); + + try { + $page = $collectors->for($account)->page($account, $capture['cursor'], $capture['cutoff']); + $result = $sync->handle($this->syncStateId, $capture['revision'], $account, $page); + } catch (AnalyticsCollectionException $exception) { + $log->record($account, $collector, $cursorLabel, $this->attempts(), $exception->category); + + if ($exception->category === 'invalid_cursor') { + $this->handleInvalidCursor($sync, $account, $capture['revision']); + + return; + } + + $transient = in_array($exception->category, ['transient', 'rate_limited'], true); + $sync->recordFailure($this->syncStateId, $capture['revision'], $exception->category, ! $transient, $account->id); + + if ($transient) { + throw $exception; + } + + return; + } + + $log->record($account, $collector, $cursorLabel, $this->attempts(), $result['terminal'] ? 'completed' : 'page_advanced'); + $metrics->handle($account, $page); + + if ($result['advanced'] && ! $result['terminal']) { + static::dispatch($account->id, $this->syncStateId)->afterCommit(); + } + } + + public function failed(?Throwable $exception): void + { + $state = AnalyticsSyncState::query()->find($this->syncStateId); + $account = SocialAccount::query()->find($this->socialAccountId); + + if ($account) { + app(AnalyticsJobLog::class)->record($account, $this->collector()->value, 'cursor:queue_failed', $this->attempts(), 'queue_failed'); + } + + if ($state && $state->social_account_id === $this->socialAccountId && ! $state->isTerminal()) { + $state->update([ + 'status' => SyncStatus::Failed, + 'last_error_category' => 'queue_failed', + ]); + } + } + + protected function mayStart(SocialAccount $account): bool + { + return true; + } + + protected function restartTerminal(): bool + { + return false; + } + + protected function resetInvalidCursor(AdvanceAnalyticsSyncState $sync, SocialAccount $account, int $revision): void + { + if ($sync->resetInvalidCursor($this->syncStateId, $revision, $account->id)) { + static::dispatch($account->id, $this->syncStateId)->afterCommit(); + } + } + + abstract protected function handleInvalidCursor(AdvanceAnalyticsSyncState $sync, SocialAccount $account, int $revision): void; +} diff --git a/app/Jobs/Analytics/BackfillAccountPublications.php b/app/Jobs/Analytics/BackfillAccountPublications.php new file mode 100644 index 000000000..a6bf44871 --- /dev/null +++ b/app/Jobs/Analytics/BackfillAccountPublications.php @@ -0,0 +1,30 @@ +stopExpiredReconnectionCursor($this->syncStateId, $revision, $account); + + if ($discoveryStateId) { + DiscoverAccountPublications::dispatch($account->id, $discoveryStateId)->afterCommit(); + + return; + } + + $this->resetInvalidCursor($sync, $account, $revision); + } +} diff --git a/app/Jobs/Analytics/BackfillTryPostPublications.php b/app/Jobs/Analytics/BackfillTryPostPublications.php new file mode 100644 index 000000000..f69fa2ad6 --- /dev/null +++ b/app/Jobs/Analytics/BackfillTryPostPublications.php @@ -0,0 +1,69 @@ + */ + public function backoff(): array + { + return [60, 300]; + } + + /** @param list $postPlatformIds */ + public function __construct(public array $postPlatformIds) + { + $this->onQueue('analytics'); + } + + public function handle(SyncTryPostPublication $sync, ResolveAnalyticsAccountKey $accountKeys): void + { + $identities = []; + + PostPlatform::query() + ->published() + ->includedInAnalytics() + ->whereIn('id', $this->postPlatformIds) + ->whereNotNull('social_account_id') + ->with(['post', 'socialAccount']) + ->get() + ->each(function (PostPlatform $postPlatform) use ($sync, $accountKeys, &$identities): void { + $account = $postPlatform->socialAccount; + + if (! $account) { + return; + } + + $identities[$account->id] ??= TryPostPublicationIdentity::fromAccount( + $account, + $accountKeys->for($account), + ); + + $sync->fromIdentity($identities[$account->id], $postPlatform, $account); + }); + } + + public function failed(?Throwable $exception): void + { + Log::error('Analytics TryPost publication backfill failed', [ + 'first_post_platform_id' => $this->postPlatformIds[0] ?? null, + 'post_platform_count' => count($this->postPlatformIds), + 'exception' => $exception, + ]); + } +} diff --git a/app/Jobs/Analytics/BootstrapAccountAnalytics.php b/app/Jobs/Analytics/BootstrapAccountAnalytics.php new file mode 100644 index 000000000..ca334ec28 --- /dev/null +++ b/app/Jobs/Analytics/BootstrapAccountAnalytics.php @@ -0,0 +1,191 @@ + */ + public function backoff(): array + { + return [60, 300]; + } + + public function __construct(public string $socialAccountId, public bool $refreshOnTerminal = false) + { + $this->onQueue('analytics'); + } + + public function handle(): void + { + $this->handleFor($this->socialAccountId); + } + + public function failed(?Throwable $exception): void + { + Log::error('Analytics account bootstrap failed', [ + 'social_account_id' => $this->socialAccountId, + 'exception' => $exception, + ]); + } + + public function handleFor(string $socialAccountId): void + { + $account = SocialAccount::query() + ->connected() + ->active() + ->includedInAnalytics() + ->find($socialAccountId); + + if (! $account || ! app(PublicationHistoryCollectorFactory::class)->supports($account->platform)) { + return; + } + + [$backfill, $backfillRebound] = $this->stateFor($account, SyncCollector::PublicationBackfill, [ + 'status' => SyncStatus::Pending, + 'checkpoint' => ['cursor' => null, 'revision' => 0], + 'target_since' => CarbonImmutable::now('UTC')->subDays(365), + ]); + + $historicalNewest = null; + $recoveredWithoutCheckpoint = false; + + if ($backfill->wasRecentlyCreated) { + $historicalPublications = AnalyticsPublication::query() + ->where(AnalyticsSyncState::providerIdentityFor($account)) + ->where('origin', PublicationOrigin::External) + ->whereNull('social_account_id'); + $historicalNewest = $historicalPublications->max('provider_published_at'); + $recoveredWithoutCheckpoint = $historicalNewest !== null; + } + + if ($recoveredWithoutCheckpoint) { + $backfill->update([ + 'status' => SyncStatus::Partial, + 'oldest_reached_at' => $historicalPublications->min('provider_published_at'), + 'high_watermark_at' => $historicalNewest, + 'last_error_category' => 'prior_checkpoint_unavailable', + ]); + } + + [$discovery] = $this->stateFor($account, SyncCollector::PublicationDiscovery, [ + 'status' => SyncStatus::Pending, + 'checkpoint' => ['cursor' => null, 'revision' => 0], + ]); + + if ($recoveredWithoutCheckpoint && ! $discovery->high_watermark_at) { + $discovery->update(['high_watermark_at' => $historicalNewest]); + } + + if ($backfillRebound || $recoveredWithoutCheckpoint) { + AnalyticsPublication::query() + ->where(AnalyticsSyncState::providerIdentityFor($account)) + ->where('platform', $account->platform) + ->whereNull('social_account_id') + ->update(['social_account_id' => $account->id]); + } + + $resumeFailed = $backfill->status === SyncStatus::Failed; + $retryPartial = $backfill->status === SyncStatus::Partial + && ! $backfillRebound + && ! $recoveredWithoutCheckpoint + && $backfill->last_error_category !== 'prior_checkpoint_unavailable'; + + if ($resumeFailed || $retryPartial) { + $backfill->update([ + 'status' => SyncStatus::Pending, + 'checkpoint' => [ + 'cursor' => $resumeFailed + ? data_get($backfill->checkpoint, 'cursor') + : null, + 'revision' => (int) data_get($backfill->checkpoint, 'revision', 0), + ...($account->platform === Platform::X + ? ['seen_count' => $resumeFailed + ? (int) data_get($backfill->checkpoint, 'seen_count', 0) + : 0] + : []), + ...($resumeFailed && data_get($backfill->checkpoint, 'resumed_after_disconnect') + ? ['resumed_after_disconnect' => true] + : []), + ], + ]); + } + + if (! $backfill->isTerminal()) { + BackfillAccountPublications::dispatch($account->id, $backfill->id)->afterCommit(); + } elseif ($backfillRebound || $recoveredWithoutCheckpoint || $this->refreshOnTerminal) { + DiscoverAccountPublications::dispatch($account->id, $discovery->id)->afterCommit(); + } + } + + /** + * @param array $defaults + * @return array{AnalyticsSyncState, bool} + */ + private function stateFor(SocialAccount $account, SyncCollector $collector, array $defaults): array + { + $identity = AnalyticsSyncState::identityFor($account); + $state = AnalyticsSyncState::query() + ->where('social_account_id', $account->id) + ->forCollector($collector) + ->first() + ?? AnalyticsSyncState::query() + ->forIdentity($account) + ->forCollector($collector) + ->first() + ?? AnalyticsSyncState::query()->firstOrCreate( + [...$identity, 'collector' => $collector], + ['social_account_id' => $account->id, ...$defaults], + ); + + $rebound = $state->social_account_id !== $account->id; + + if ($rebound + || $state->workspace_id !== $identity['workspace_id'] + || $state->network !== $identity['network'] + || $state->platform_user_id !== $identity['platform_user_id'] + || $state->identity_key !== $identity['identity_key']) { + $checkpoint = $state->checkpoint ?? []; + $state->update([ + ...$identity, + 'social_account_id' => $account->id, + 'status' => $rebound && $state->status === SyncStatus::Running + ? SyncStatus::Pending + : $state->status, + 'checkpoint' => $rebound ? [ + ...$checkpoint, + 'cursor' => $collector === SyncCollector::PublicationDiscovery + ? null + : data_get($checkpoint, 'cursor'), + 'revision' => (int) data_get($checkpoint, 'revision', 0) + 1, + ...($collector === SyncCollector::PublicationBackfill + && filled(data_get($checkpoint, 'cursor')) + && $state->oldest_reached_at !== null + ? ['resumed_after_disconnect' => true] + : []), + ] : $checkpoint, + ]); + } + + return [$state, $rebound]; + } +} diff --git a/app/Jobs/Analytics/CollectAccountDailySnapshot.php b/app/Jobs/Analytics/CollectAccountDailySnapshot.php new file mode 100644 index 000000000..8894f61c0 --- /dev/null +++ b/app/Jobs/Analytics/CollectAccountDailySnapshot.php @@ -0,0 +1,150 @@ +onQueue('analytics'); + } + + /** @return array */ + public function middleware(): array + { + return [ + (new WithoutOverlapping("analytics-followers:{$this->socialAccountId}:{$this->observationDate}")) + ->releaseAfter(300) + ->expireAfter($this->timeout + 30), + ]; + } + + public function retryUntil(): CarbonImmutable + { + return CarbonImmutable::parse($this->observationDate, 'UTC')->endOfDay(); + } + + public function handle( + FollowerCollectorFactory $collectors, + ResolveAnalyticsAccountKey $accountKeys, + WriteAccountDailySnapshot $writer, + AnalyticsJobLog $log, + ): void { + $account = SocialAccount::query() + ->connected() + ->active() + ->find($this->socialAccountId); + + if (! $account + || ! $collectors->supports($account->platform)) { + return; + } + + $alreadyCollected = AnalyticsAccountDailySnapshot::query() + ->where('workspace_id', $account->workspace_id) + ->where('social_account_key', $accountKeys->for($account)) + ->whereDate('date', $this->observationDate) + ->where('provenance', ObservationProvenance::Actual) + ->exists(); + + if ($alreadyCollected) { + return; + } + + try { + $observation = $collectors->for($account->platform)->collect( + $account, + CarbonImmutable::parse($this->observationDate, 'UTC'), + ); + $writer->handle($account, $observation); + $log->record($account, 'followers', $this->observationDate, $this->attempts(), 'actual'); + } catch (AnalyticsCollectionException $exception) { + if (in_array($exception->category, ['authentication', 'permission'], true)) { + $log->record($account, 'followers', $this->observationDate, $this->attempts(), $exception->category); + + return; + } + + if (! in_array($exception->category, ['transient', 'rate_limited'], true)) { + $log->record($account, 'followers', $this->observationDate, $this->attempts(), $exception->category); + + return; + } + + $this->retryTransient($account, $exception->category, $exception->retryAt, $log); + } catch (ConnectionException) { + $this->retryTransient($account, 'transient', null, $log); + } + } + + private function retryTransient(SocialAccount $account, string $category, ?CarbonImmutable $providerRetryAt, AnalyticsJobLog $log): void + { + if ($this->attempts() >= 6) { + $log->record($account, 'followers', $this->observationDate, $this->attempts(), $category); + + return; + } + + $nextAttempt = $this->nextAttemptAt($providerRetryAt); + + if ($nextAttempt) { + $log->record($account, 'followers', $this->observationDate, $this->attempts(), $category, $nextAttempt->toIso8601String()); + $this->release($nextAttempt); + + return; + } + + $log->record($account, 'followers', $this->observationDate, $this->attempts(), 'retry_window_exhausted'); + } + + private function nextAttemptAt(?CarbonImmutable $providerRetryAt): ?CarbonImmutable + { + $now = CarbonImmutable::now('UTC'); + $endOfDay = CarbonImmutable::parse($this->observationDate, 'UTC')->endOfDay(); + $nextWindow = null; + + foreach ([6, 10, 14, 18, 22] as $hour) { + $window = CarbonImmutable::parse($this->observationDate, 'UTC')->setTime($hour, 0); + + if ($window->greaterThanOrEqualTo($now)) { + $nextWindow = $window; + + break; + } + } + + if (! $nextWindow) { + return null; + } + + $nextAttempt = $providerRetryAt && $providerRetryAt->greaterThan($nextWindow) + ? $providerRetryAt + : $nextWindow; + + return $nextAttempt->lessThanOrEqualTo($endOfDay) ? $nextAttempt : null; + } +} diff --git a/app/Jobs/Analytics/CollectPublicationMetrics.php b/app/Jobs/Analytics/CollectPublicationMetrics.php new file mode 100644 index 000000000..802c24140 --- /dev/null +++ b/app/Jobs/Analytics/CollectPublicationMetrics.php @@ -0,0 +1,191 @@ +onQueue('analytics'); + } + + /** @return list */ + public function middleware(): array + { + return [ + new RateLimited('analytics-publications'), + (new WithoutOverlapping("analytics-metrics:{$this->publicationId}:{$this->observationDate}")) + ->releaseAfter(300) + ->expireAfter($this->timeout + 30), + ]; + } + + public function providerRateLimitKey(): string + { + $publication = AnalyticsPublication::query()->find($this->publicationId); + + return $publication?->platform->network() ?? 'missing'; + } + + public function retryUntil(): CarbonImmutable + { + return CarbonImmutable::parse($this->observationDate, 'UTC')->endOfDay(); + } + + public function handle( + PublicationMetricsCollectorFactory $collectors, + UpsertAnalyticsPublication $publications, + WritePublicationDailySnapshot $writer, + AnalyticsJobLog $log, + ): void { + $date = CarbonImmutable::parse($this->observationDate, 'UTC'); + + $publication = AnalyticsPublication::query()->available()->find($this->publicationId); + $account = $publication ? SocialAccount::query() + ->connected() + ->active() + ->includedInAnalytics() + ->find($publication->social_account_id) : null; + + if (! $publication || ! $account || ! $this->eligible($publication, $account, $date)) { + return; + } + + $publication->setRelation('socialAccount', $account); + + try { + $collector = $collectors->for($publication->platform); + + if ($collector instanceof TikTokPublicationMetricsCollector + && $publication->post_platform_id + && ! ctype_digit($publication->remote_id)) { + $publications->reconcileTikTokPublicId($publication, $collector->publicVideoId($publication)); + } + + $observation = $collector->collect($publication, $date); + $writer->handle($publication, $observation); + $log->record($account, 'publication_metrics', $this->observationDate, $this->attempts(), 'actual'); + } catch (AnalyticsCollectionException $exception) { + $log->record($account, 'publication_metrics', $this->observationDate, $this->attempts(), $exception->category); + $this->retry($publication, $date, $exception->category, $exception->retryAt, $log); + } catch (ConnectionException) { + $log->record($account, 'publication_metrics', $this->observationDate, $this->attempts(), 'transient'); + $this->retry($publication, $date, 'transient', null, $log); + } + } + + private function eligible(AnalyticsPublication $publication, SocialAccount $account, CarbonImmutable $date): bool + { + if ($account->workspace_id !== $publication->workspace_id + || $account->platform !== $publication->platform + || $account->platform_user_id !== $publication->platform_user_id) { + return false; + } + + $isStory = $publication->content_type === PublicationContentType::Story + && in_array($publication->platform, [Platform::Instagram, Platform::InstagramFacebook], true); + + if ($isStory && CarbonImmutable::now('UTC')->greaterThanOrEqualTo($publication->provider_published_at->addDay())) { + return false; + } + + if (! $this->baseline && ! $isStory) { + $ageLimit = $publication->platform === Platform::X ? 20 : 30; + + if ($publication->provider_published_at->lessThan($date->subDays($ageLimit)->startOfDay())) { + return false; + } + } + + return $this->refreshSameDay || ! AnalyticsPublicationDailySnapshot::query() + ->where('publication_id', $publication->id) + ->whereDate('date', $this->observationDate) + ->exists(); + } + + private function retry( + AnalyticsPublication $publication, + CarbonImmutable $date, + string $category, + ?CarbonImmutable $providerRetryAt, + AnalyticsJobLog $log, + ): void { + if (! in_array($category, ['rate_limited', 'transient', 'delayed'], true) || $this->retryNumber >= 5) { + return; + } + + $now = CarbonImmutable::now('UTC'); + $isStory = $publication->content_type === PublicationContentType::Story; + $next = $isStory ? $now->addMinutes(30) : null; + + if (! $next) { + foreach ([6, 10, 14, 18, 22] as $hour) { + $window = $date->setTime($hour, 0); + + if ($window->greaterThan($now)) { + $next = $window; + + break; + } + } + } + + if (! $next) { + return; + } + + if ($providerRetryAt && $providerRetryAt->greaterThan($next)) { + $next = $providerRetryAt; + } + + if ($next->greaterThan($date->endOfDay()) + || ($isStory && $next->greaterThanOrEqualTo($publication->provider_published_at->addDay()))) { + return; + } + + $account = $publication->socialAccount; + + if ($account) { + $log->record($account, 'publication_metrics', $this->observationDate, $this->attempts(), 'retry_scheduled', $next->toIso8601String()); + } + + self::dispatch( + $publication->id, + $this->observationDate, + $this->baseline, + $this->refreshSameDay, + $this->retryNumber + 1, + )->delay($next)->afterCommit(); + } +} diff --git a/app/Jobs/Analytics/DiscoverAccountPublications.php b/app/Jobs/Analytics/DiscoverAccountPublications.php new file mode 100644 index 000000000..425042d0c --- /dev/null +++ b/app/Jobs/Analytics/DiscoverAccountPublications.php @@ -0,0 +1,37 @@ +where('social_account_id', $account->id) + ->forCollector(SyncCollector::PublicationBackfill) + ->terminal() + ->exists(); + } + + protected function restartTerminal(): bool + { + return AnalyticsSyncState::query()->find($this->syncStateId)?->isTerminal() ?? false; + } + + protected function handleInvalidCursor(AdvanceAnalyticsSyncState $sync, SocialAccount $account, int $revision): void + { + $this->resetInvalidCursor($sync, $account, $revision); + } +} diff --git a/app/Jobs/Analytics/FinalizeAccountDailySnapshot.php b/app/Jobs/Analytics/FinalizeAccountDailySnapshot.php new file mode 100644 index 000000000..dad42e5c5 --- /dev/null +++ b/app/Jobs/Analytics/FinalizeAccountDailySnapshot.php @@ -0,0 +1,101 @@ + */ + public function backoff(): array + { + return [300, 900]; + } + + public function __construct(public string $socialAccountId, public string $observationDate) + { + $this->onQueue('analytics'); + } + + public function failed(?Throwable $exception): void + { + Log::error('Analytics follower snapshot finalization failed', [ + 'social_account_id' => $this->socialAccountId, + 'observation_date' => $this->observationDate, + 'exception' => $exception, + ]); + } + + public function handle( + ResolveAnalyticsAccountKey $accountKeys, + WriteAccountDailySnapshot $writer, + AnalyticsJobLog $log, + ): void { + $account = SocialAccount::query() + ->connected() + ->active() + ->includedInAnalytics() + ->find($this->socialAccountId); + + if (! $account) { + return; + } + + $date = CarbonImmutable::parse($this->observationDate, 'UTC'); + $hasActual = AnalyticsAccountDailySnapshot::query() + ->where('workspace_id', $account->workspace_id) + ->whereDate('date', $this->observationDate) + ->where('social_account_key', $accountKeys->for($account)) + ->where('provenance', ObservationProvenance::Actual) + ->exists(); + + if ($hasActual) { + return; + } + + $previous = AnalyticsAccountDailySnapshot::query() + ->where('workspace_id', $account->workspace_id) + ->where('network', $account->platform->network()) + ->where('platform_user_id', $account->platform_user_id) + ->whereDate('date', '<', $this->observationDate) + ->whereNotNull('followers_count') + ->latest('date') + ->first(); + + if (! $previous) { + $log->record($account, 'followers', $this->observationDate, $this->attempts(), 'unavailable_no_history'); + + return; + } + + $writer->handle($account, new AccountDailyObservation( + date: $date, + followers: $previous->followers_count, + provenance: ObservationProvenance::CarriedForward, + precision: $previous->precision, + providerObservedAt: $previous->provider_observed_at, + collectedAt: CarbonImmutable::now('UTC'), + metrics: $previous->metrics ?? [], + )); + $log->record($account, 'followers', $this->observationDate, $this->attempts(), 'carried_forward'); + } +} diff --git a/app/Jobs/Analytics/FinalizeAccountDailySnapshots.php b/app/Jobs/Analytics/FinalizeAccountDailySnapshots.php new file mode 100644 index 000000000..d8ef75df4 --- /dev/null +++ b/app/Jobs/Analytics/FinalizeAccountDailySnapshots.php @@ -0,0 +1,57 @@ + */ + public function backoff(): array + { + return [300, 900]; + } + + public function __construct(public ?string $observationDate = null, public int $daysAgo = 0) + { + $this->onQueue('analytics'); + } + + public function failed(?Throwable $exception): void + { + Log::error('Analytics follower snapshot finalization dispatch failed', [ + 'observation_date' => $this->observationDate, + 'days_ago' => $this->daysAgo, + 'exception' => $exception, + ]); + } + + public function handle(): void + { + $date = $this->observationDate + ?? CarbonImmutable::now('UTC')->subDays($this->daysAgo)->toDateString(); + + SocialAccount::query() + ->connected() + ->active() + ->includedInAnalytics() + ->reorder() + ->lazyById(200) + ->each(function (SocialAccount $account) use ($date): void { + FinalizeAccountDailySnapshot::dispatch($account->id, $date); + }); + } +} diff --git a/app/Jobs/Analytics/ScheduleInstagramStoryMetrics.php b/app/Jobs/Analytics/ScheduleInstagramStoryMetrics.php new file mode 100644 index 000000000..f3d0dfe07 --- /dev/null +++ b/app/Jobs/Analytics/ScheduleInstagramStoryMetrics.php @@ -0,0 +1,60 @@ +onQueue('analytics'); + } + + public function handle(): void + { + $publication = AnalyticsPublication::query()->available()->find($this->publicationId); + + if (! $publication + || $publication->content_type !== PublicationContentType::Story + || ! in_array($publication->platform, [Platform::Instagram, Platform::InstagramFacebook], true)) { + return; + } + + $now = CarbonImmutable::now('UTC'); + $publishedAt = $publication->provider_published_at; + $expiresAt = $publishedAt->addDay(); + + if ($now->greaterThanOrEqualTo($expiresAt)) { + return; + } + + $checkpoints = [ + $now, + $publishedAt->addHours(6), + $expiresAt->subMinutes(30), + ]; + + foreach ($checkpoints as $checkpoint) { + if ($checkpoint->lessThan($now) || $checkpoint->greaterThanOrEqualTo($expiresAt)) { + continue; + } + + CollectPublicationMetrics::dispatch( + $publication->id, + $checkpoint->toDateString(), + false, + true, + )->delay($checkpoint)->afterCommit(); + } + } +} diff --git a/app/Jobs/Analytics/SyncTryPostPublication.php b/app/Jobs/Analytics/SyncTryPostPublication.php new file mode 100644 index 000000000..8f619188d --- /dev/null +++ b/app/Jobs/Analytics/SyncTryPostPublication.php @@ -0,0 +1,39 @@ +onQueue('analytics'); + } + + public function handle(SyncTryPostPublicationAction $sync, QueuePublicationMetricsForPage $metrics): void + { + $postPlatform = PostPlatform::query() + ->published() + ->with('post') + ->find($this->postPlatformId); + + if (! $postPlatform || ! filled($postPlatform->platform_post_id)) { + return; + } + + $publication = $sync->fromIdentity($this->identity, $postPlatform); + $metrics->queue($publication); + } +} diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php index d20b18bdc..14c8f992a 100644 --- a/app/Mcp/Servers/TryPostServer.php +++ b/app/Mcp/Servers/TryPostServer.php @@ -4,6 +4,8 @@ namespace App\Mcp\Servers; +use App\Mcp\Tools\Analytics\GetAnalyticsPublicationTool; +use App\Mcp\Tools\Analytics\GetAnalyticsReportTool; use App\Mcp\Tools\ApiKey\CreateApiKeyTool; use App\Mcp\Tools\ApiKey\DeleteApiKeyTool; use App\Mcp\Tools\ApiKey\ListApiKeysTool; @@ -64,7 +66,7 @@ #[Name('TryPost')] #[Version('1.0.0')] #[Icon('images/trypost/icon.png', mimeType: 'image/png')] -#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, the Asset Library, signatures, labels, social accounts, workspaces, outgoing webhooks, repurposes (auto-replicating videos posted outside TryPost), and API keys.')] +#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, analytics, the Asset Library, signatures, labels, social accounts, workspaces, outgoing webhooks, repurposes (auto-replicating videos posted outside TryPost), and API keys.')] class TryPostServer extends Server { public int $defaultPaginationLength = 100; @@ -83,6 +85,10 @@ class TryPostServer extends Server AttachMediaFromUploadTool::class, GetPostMetricsTool::class, + // Analytics + GetAnalyticsReportTool::class, + GetAnalyticsPublicationTool::class, + // Assets ListAssetsTool::class, GetAssetTool::class, diff --git a/app/Mcp/Tools/Analytics/GetAnalyticsPublicationTool.php b/app/Mcp/Tools/Analytics/GetAnalyticsPublicationTool.php new file mode 100644 index 000000000..fbbd6f777 --- /dev/null +++ b/app/Mcp/Tools/Analytics/GetAnalyticsPublicationTool.php @@ -0,0 +1,53 @@ +authorizeCurrentWorkspace($request, 'view', 'Not authorized to view workspace analytics.'); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $validated = $request->validate([ + 'publication_id' => ['required', 'uuid'], + ]); + + try { + return Response::structured($this->analytics->latestForWorkspacePublication($workspace, $validated['publication_id'])); + } catch (ModelNotFoundException) { + return Response::error('Publication not found.'); + } + } + + /** @return array */ + public function schema(JsonSchema $schema): array + { + return [ + 'publication_id' => $schema->string()->required()->description('UUID of a publication in the workspace analytics report.'), + ]; + } +} diff --git a/app/Mcp/Tools/Analytics/GetAnalyticsReportTool.php b/app/Mcp/Tools/Analytics/GetAnalyticsReportTool.php new file mode 100644 index 000000000..e12d3b869 --- /dev/null +++ b/app/Mcp/Tools/Analytics/GetAnalyticsReportTool.php @@ -0,0 +1,50 @@ +authorizeCurrentWorkspace($request, 'view', 'Not authorized to view workspace analytics.'); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $selected = $request->validate([ + 'start' => ['sometimes', 'required', 'date_format:Y-m-d'], + 'end' => ['sometimes', 'required', 'date_format:Y-m-d', 'after_or_equal:start'], + ]); + + return Response::structured($this->analytics->forSelection($workspace, $selected)); + } + + /** @return array */ + public function schema(JsonSchema $schema): array + { + return [ + 'start' => $schema->string()->description('Optional start date in YYYY-MM-DD format.'), + 'end' => $schema->string()->description('Optional end date in YYYY-MM-DD format.'), + ]; + } +} diff --git a/app/Mcp/Tools/Post/GetPostMetricsTool.php b/app/Mcp/Tools/Post/GetPostMetricsTool.php index b6ceb14f4..cd2e7194f 100644 --- a/app/Mcp/Tools/Post/GetPostMetricsTool.php +++ b/app/Mcp/Tools/Post/GetPostMetricsTool.php @@ -4,8 +4,8 @@ namespace App\Mcp\Tools\Post; +use App\Actions\Analytics\ReadPublicationAnalytics; use App\Models\Post; -use App\Services\Post\PostMetricsFetcher; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -15,7 +15,7 @@ use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('Fetch engagement metrics (likes, comments, shares, etc.) for a published post across all platforms it was posted to. Returns "unsupported" entries for platforms that do not expose post-level metrics or for unpublished platforms.')] +#[Description('Read all latest saved analytics for a TryPost post across its published platforms, including reactions, comments, saves, reach, views, and watch time where supported. Values may lag the provider until the next analytics job. Returns "unsupported" for excluded or unpublished platforms.')] class GetPostMetricsTool extends Tool { public function handle(Request $request): Response|ResponseFactory @@ -34,7 +34,7 @@ public function handle(Request $request): Response|ResponseFactory return Response::structured([ 'post_id' => $post->id, - 'platforms' => app(PostMetricsFetcher::class)->forPost($post)->all(), + 'platforms' => app(ReadPublicationAnalytics::class)->forPost($post)->all(), ]); } diff --git a/app/Models/AnalyticsAccountDailySnapshot.php b/app/Models/AnalyticsAccountDailySnapshot.php new file mode 100644 index 000000000..4e82f812b --- /dev/null +++ b/app/Models/AnalyticsAccountDailySnapshot.php @@ -0,0 +1,63 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'workspace_id', + 'social_account_id', + 'social_account_key', + 'network', + 'platform_user_id', + 'platform', + 'account_display_name', + 'account_username', + 'account_avatar_url', + 'date', + 'followers_count', + 'metrics', + 'provenance', + 'precision', + 'provider_observed_at', + 'collected_at', + ]; + + protected function casts(): array + { + return [ + 'platform' => Platform::class, + 'date' => 'immutable_date', + 'followers_count' => 'integer', + 'metrics' => 'array', + 'provenance' => ObservationProvenance::class, + 'precision' => MetricPrecision::class, + 'provider_observed_at' => 'immutable_datetime', + 'collected_at' => 'immutable_datetime', + ]; + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function socialAccount(): BelongsTo + { + return $this->belongsTo(SocialAccount::class); + } +} diff --git a/app/Models/AnalyticsPublication.php b/app/Models/AnalyticsPublication.php new file mode 100644 index 000000000..d897c6e7e --- /dev/null +++ b/app/Models/AnalyticsPublication.php @@ -0,0 +1,90 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'workspace_id', + 'social_account_id', + 'social_account_key', + 'post_platform_id', + 'network', + 'platform_user_id', + 'platform', + 'remote_id', + 'provider_published_at', + 'origin', + 'content_type', + 'availability', + 'provider_content_type', + 'permalink', + 'excerpt', + 'preview_metadata', + 'account_display_name', + 'account_username', + 'account_avatar_url', + 'first_seen_at', + 'last_seen_at', + 'provider_synced_at', + 'provider_metadata', + ]; + + protected function casts(): array + { + return [ + 'platform' => Platform::class, + 'provider_published_at' => 'immutable_datetime', + 'origin' => PublicationOrigin::class, + 'content_type' => PublicationContentType::class, + 'availability' => PublicationAvailability::class, + 'preview_metadata' => 'array', + 'first_seen_at' => 'immutable_datetime', + 'last_seen_at' => 'immutable_datetime', + 'provider_synced_at' => 'immutable_datetime', + 'provider_metadata' => 'array', + ]; + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function socialAccount(): BelongsTo + { + return $this->belongsTo(SocialAccount::class); + } + + public function postPlatform(): BelongsTo + { + return $this->belongsTo(PostPlatform::class); + } + + public function dailySnapshots(): HasMany + { + return $this->hasMany(AnalyticsPublicationDailySnapshot::class, 'publication_id'); + } + + public function scopeAvailable(Builder $query): Builder + { + return $query->where('availability', PublicationAvailability::Available); + } +} diff --git a/app/Models/AnalyticsPublicationDailySnapshot.php b/app/Models/AnalyticsPublicationDailySnapshot.php new file mode 100644 index 000000000..9d21921e2 --- /dev/null +++ b/app/Models/AnalyticsPublicationDailySnapshot.php @@ -0,0 +1,65 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'publication_id', + 'date', + 'collected_at', + 'provider_observed_at', + 'metrics', + 'reactions_count', + 'comments_count', + 'shares_count', + 'saves_count', + 'views_count', + 'impressions_count', + 'reach_count', + 'engagement_count', + 'exposure_count', + 'exposure_kind', + 'watch_time_milliseconds', + 'average_watch_time_milliseconds', + ]; + + protected function casts(): array + { + return [ + 'date' => 'immutable_date', + 'collected_at' => 'immutable_datetime', + 'provider_observed_at' => 'immutable_datetime', + 'metrics' => 'array', + 'reactions_count' => 'integer', + 'comments_count' => 'integer', + 'shares_count' => 'integer', + 'saves_count' => 'integer', + 'views_count' => 'integer', + 'impressions_count' => 'integer', + 'reach_count' => 'integer', + 'engagement_count' => 'integer', + 'exposure_count' => 'integer', + 'exposure_kind' => ExposureKind::class, + 'watch_time_milliseconds' => 'integer', + 'average_watch_time_milliseconds' => 'integer', + ]; + } + + public function publication(): BelongsTo + { + return $this->belongsTo(AnalyticsPublication::class, 'publication_id'); + } +} diff --git a/app/Models/AnalyticsSyncState.php b/app/Models/AnalyticsSyncState.php new file mode 100644 index 000000000..ea8ae7b0e --- /dev/null +++ b/app/Models/AnalyticsSyncState.php @@ -0,0 +1,105 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'social_account_id', + 'workspace_id', + 'network', + 'platform_user_id', + 'identity_key', + 'collector', + 'status', + 'checkpoint', + 'target_since', + 'oldest_reached_at', + 'high_watermark_at', + 'last_success_at', + 'last_error_category', + ]; + + protected function casts(): array + { + return [ + 'collector' => SyncCollector::class, + 'status' => SyncStatus::class, + 'checkpoint' => 'array', + 'target_since' => 'immutable_datetime', + 'oldest_reached_at' => 'immutable_datetime', + 'high_watermark_at' => 'immutable_datetime', + 'last_success_at' => 'immutable_datetime', + ]; + } + + public function socialAccount(): BelongsTo + { + return $this->belongsTo(SocialAccount::class); + } + + public function scopeForCollector(Builder $query, SyncCollector $collector): Builder + { + return $query->where('collector', $collector); + } + + public function scopeForIdentity(Builder $query, SocialAccount $account): Builder + { + return $query->where(self::identityFor($account)); + } + + /** @return array{workspace_id: string, network: string, platform_user_id: string, identity_key: string} */ + public static function identityFor(SocialAccount $account): array + { + $identity = self::providerIdentityFor($account); + + return [ + ...$identity, + 'identity_key' => hash('sha256', json_encode(array_values($identity), JSON_THROW_ON_ERROR)), + ]; + } + + /** @return array{workspace_id: string, network: string, platform_user_id: string} */ + public static function providerIdentityFor(SocialAccount $account): array + { + return [ + 'workspace_id' => $account->workspace_id, + 'network' => $account->platform->network(), + 'platform_user_id' => $account->platform_user_id, + ]; + } + + public function scopeTerminal(Builder $query): Builder + { + return $query->whereIn('status', [ + SyncStatus::Complete, + SyncStatus::Partial, + SyncStatus::ProviderLimited, + SyncStatus::Failed, + ]); + } + + public function isTerminal(): bool + { + return in_array($this->status, [ + SyncStatus::Complete, + SyncStatus::Partial, + SyncStatus::ProviderLimited, + SyncStatus::Failed, + ], true); + } +} diff --git a/app/Models/PostPlatform.php b/app/Models/PostPlatform.php index c0b0ffbf4..bce5c3cbd 100644 --- a/app/Models/PostPlatform.php +++ b/app/Models/PostPlatform.php @@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Facades\Storage; #[ObservedBy(PostPlatformObserver::class)] @@ -70,6 +71,11 @@ public function socialAccount(): BelongsTo return $this->belongsTo(SocialAccount::class); } + public function analyticsPublication(): HasOne + { + return $this->hasOne(AnalyticsPublication::class); + } + /** * Only platforms still enabled for publishing — disabled ones are * excluded from PublishPost, so anything else that mirrors publish @@ -90,6 +96,11 @@ public function scopePublished(Builder $query): Builder return $query->where('post_platforms.status', Status::Published); } + public function scopeIncludedInAnalytics(Builder $query): Builder + { + return $query->whereIn('post_platforms.platform', SocialPlatform::analyticsValues()); + } + /** * Get display name, falling back to snapshot if account was deleted. */ diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 804836ff2..9b418bc42 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Actions\Analytics\DispatchAccountAnalytics; use App\Enums\Notification\Channel; use App\Enums\Notification\Type; use App\Enums\PostPlatform\ContentType; @@ -108,7 +109,7 @@ public static function connectIdentity( // Two popups finishing at once for the same network must not interleave // a reconnect's update-and-realign transaction with a fresh insert. try { - return Cache::lock("social_connect:{$workspace->id}:{$platform->network()}", 10) + $account = Cache::lock("social_connect:{$workspace->id}:{$platform->network()}", 10) ->block(5, fn (): self => static::persistIdentity( $workspace, $platform, @@ -116,6 +117,14 @@ public static function connectIdentity( $values, $reconnect, )); + + if ((! $account->wasRecentlyCreated || $reconnect?->id === $account->id) + && ! $account->wasChanged('status') + && ! $account->wasChanged('is_active')) { + app(DispatchAccountAnalytics::class)->handle($account); + } + + return $account; } catch (LockTimeoutException) { throw NetworkAlreadyConnectedException::connectInProgress($platform); } @@ -215,6 +224,11 @@ public function postPlatforms(): HasMany return $this->hasMany(PostPlatform::class); } + public function analyticsSyncStates(): HasMany + { + return $this->hasMany(AnalyticsSyncState::class); + } + protected function isTokenExpired(): Attribute { return Attribute::make( @@ -426,4 +440,14 @@ public function scopeActive(Builder $query): Builder { return $query->where('is_active', true)->orderBy('platform'); } + + public function scopeConnected(Builder $query): Builder + { + return $query->where('status', Status::Connected); + } + + public function scopeIncludedInAnalytics(Builder $query): Builder + { + return $query->whereIn('platform', SocialPlatform::analyticsValues()); + } } diff --git a/app/Observers/PostPlatformObserver.php b/app/Observers/PostPlatformObserver.php index b56da0b09..49fba2694 100644 --- a/app/Observers/PostPlatformObserver.php +++ b/app/Observers/PostPlatformObserver.php @@ -4,18 +4,29 @@ namespace App\Observers; +use App\Actions\Analytics\ResolveAnalyticsAccountKey; +use App\Dto\Analytics\TryPostPublicationIdentity; use App\Enums\PostPlatform\Status; +use App\Jobs\Analytics\SyncTryPostPublication; use App\Jobs\PostHog\SyncAccountPublishingActivity; use App\Models\PostPlatform; +use App\Services\Analytics\Collectors\Followers\FollowerCollectorFactory; use App\Services\PostHogService; +use Throwable; class PostPlatformObserver { public function updated(PostPlatform $postPlatform): void { - if (! PostHogService::isEnabled() - || ! $postPlatform->wasChanged('status') - || $postPlatform->status !== Status::Published) { + if (! $postPlatform->wasChanged('status') + || $postPlatform->status !== Status::Published + || ! filled($postPlatform->platform_post_id)) { + return; + } + + $this->dispatchAnalyticsSync($postPlatform); + + if (! PostHogService::isEnabled()) { return; } @@ -31,4 +42,24 @@ public function updated(PostPlatform $postPlatform): void ->delay(now()->addSeconds(SyncAccountPublishingActivity::DEBOUNCE_SECONDS)) ->afterCommit(); } + + private function dispatchAnalyticsSync(PostPlatform $postPlatform): void + { + try { + $account = $postPlatform->loadMissing('socialAccount')->socialAccount; + + if (! $account || ! app(FollowerCollectorFactory::class)->supports($postPlatform->platform)) { + return; + } + + $identity = TryPostPublicationIdentity::fromAccount( + $account, + app(ResolveAnalyticsAccountKey::class)->for($account), + ); + + SyncTryPostPublication::dispatch($identity, $postPlatform->id)->afterCommit(); + } catch (Throwable $exception) { + report($exception); + } + } } diff --git a/app/Observers/SocialAccountObserver.php b/app/Observers/SocialAccountObserver.php index e7bf28cbf..cbc7ca99a 100644 --- a/app/Observers/SocialAccountObserver.php +++ b/app/Observers/SocialAccountObserver.php @@ -4,6 +4,7 @@ namespace App\Observers; +use App\Actions\Analytics\DispatchAccountAnalytics; use App\Enums\SocialAccount\Status; use App\Jobs\PostHog\IdentifyConnectedPlatforms; use App\Jobs\PostHog\SyncAccountUsage; @@ -16,6 +17,7 @@ class SocialAccountObserver public function created(SocialAccount $socialAccount): void { $this->syncUsageAndIdentify($socialAccount); + app(DispatchAccountAnalytics::class)->handle($socialAccount); } public function deleted(SocialAccount $socialAccount): void @@ -32,16 +34,18 @@ public function updated(SocialAccount $socialAccount): void { app(RepurposeAccountSync::class)->accountChanged($socialAccount); - if (! $socialAccount->wasChanged('status')) { - return; - } - $wasConnected = $socialAccount->getRawOriginal('status') === Status::Connected->value; $isConnected = $socialAccount->status === Status::Connected; + $connectionChanged = $socialAccount->wasChanged('status') && $wasConnected !== $isConnected; + $becameActive = $socialAccount->wasChanged('is_active') && $socialAccount->is_active; - if ($wasConnected !== $isConnected) { + if ($connectionChanged) { $this->identifyConnectedPlatforms($socialAccount); } + + if (($connectionChanged && $isConnected) || $becameActive) { + app(DispatchAccountAnalytics::class)->handle($socialAccount); + } } private function syncUsageAndIdentify(SocialAccount $socialAccount): void diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 040f423b7..07dc33ae8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,10 @@ use App\Models\AccessToken; use App\Models\Account; use App\Models\AiUsageLog; +use App\Models\AnalyticsAccountDailySnapshot; +use App\Models\AnalyticsPublication; +use App\Models\AnalyticsPublicationDailySnapshot; +use App\Models\AnalyticsSyncState; use App\Models\Invite; use App\Models\Media; use App\Models\Notification; @@ -97,6 +101,10 @@ protected function configureMorphMap(): void 'accessToken' => AccessToken::class, 'account' => Account::class, 'aiUsageLog' => AiUsageLog::class, + 'analyticsAccountDailySnapshot' => AnalyticsAccountDailySnapshot::class, + 'analyticsPublication' => AnalyticsPublication::class, + 'analyticsPublicationDailySnapshot' => AnalyticsPublicationDailySnapshot::class, + 'analyticsSyncState' => AnalyticsSyncState::class, 'invite' => Invite::class, 'media' => Media::class, 'notification' => Notification::class, @@ -146,6 +154,11 @@ protected function configureRateLimiting(): void fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip()), ); + RateLimiter::for( + 'analytics-publications', + fn (object $job): Limit => Limit::perMinute(30)->by($job->providerRateLimitKey()), + ); + // Signed media uploads (api.uploads.store). MCP hosts share egress IPs // across tenants — key by workspace_id from the signed URL, with a high // IP backstop so one client cannot flood every workspace. diff --git a/app/Services/Analytics/Collectors/Followers/AbstractFollowerCollector.php b/app/Services/Analytics/Collectors/Followers/AbstractFollowerCollector.php new file mode 100644 index 000000000..8b4c6aa93 --- /dev/null +++ b/app/Services/Analytics/Collectors/Followers/AbstractFollowerCollector.php @@ -0,0 +1,61 @@ +withToken($account->access_token) + ->timeout(120) + ->get($url, $query); + + if ($response->successful()) { + return $response; + } + + if ($meta) { + return MetaAnalyticsResponse::successful($response, 'follower collection'); + } + + throw AnalyticsCollectionException::fromResponse($response, 'follower collection'); + } + + protected function observation( + CarbonImmutable $date, + mixed $value, + MetricPrecision $precision = MetricPrecision::Exact, + ): AccountDailyObservation { + if (! is_numeric($value)) { + throw AnalyticsCollectionException::malformed('missing follower metric'); + } + + return new AccountDailyObservation( + date: $date, + followers: (int) $value, + provenance: ObservationProvenance::Actual, + precision: $precision, + providerObservedAt: CarbonImmutable::now('UTC'), + collectedAt: CarbonImmutable::now('UTC'), + ); + } +} diff --git a/app/Services/Analytics/Collectors/Followers/BlueskyFollowerCollector.php b/app/Services/Analytics/Collectors/Followers/BlueskyFollowerCollector.php new file mode 100644 index 000000000..e65e771b7 --- /dev/null +++ b/app/Services/Analytics/Collectors/Followers/BlueskyFollowerCollector.php @@ -0,0 +1,23 @@ +get( + $account, + config('trypost.platforms.bluesky.public_appview').'/xrpc/app.bsky.actor.getProfile', + ['actor' => $account->platform_user_id], + ); + + return $this->observation($date, data_get($response->json(), 'followersCount')); + } +} diff --git a/app/Services/Analytics/Collectors/Followers/FacebookFollowerCollector.php b/app/Services/Analytics/Collectors/Followers/FacebookFollowerCollector.php new file mode 100644 index 000000000..16805e6a0 --- /dev/null +++ b/app/Services/Analytics/Collectors/Followers/FacebookFollowerCollector.php @@ -0,0 +1,24 @@ +get( + $account, + config('trypost.platforms.facebook.graph_api')."/{$account->platform_user_id}", + ['fields' => 'followers_count'], + meta: true, + ); + + return $this->observation($date, data_get($response->json(), 'followers_count')); + } +} diff --git a/app/Services/Analytics/Collectors/Followers/FollowerCollectorFactory.php b/app/Services/Analytics/Collectors/Followers/FollowerCollectorFactory.php new file mode 100644 index 000000000..2717c3156 --- /dev/null +++ b/app/Services/Analytics/Collectors/Followers/FollowerCollectorFactory.php @@ -0,0 +1,32 @@ +isIncludedInAnalytics(); + } + + public function for(Platform $platform): AbstractFollowerCollector + { + return match ($platform) { + Platform::Instagram, Platform::InstagramFacebook => app(InstagramFollowerCollector::class), + Platform::Facebook => app(FacebookFollowerCollector::class), + Platform::Threads => app(ThreadsFollowerCollector::class), + Platform::X => app(XFollowerCollector::class), + Platform::Pinterest => app(PinterestFollowerCollector::class), + Platform::YouTube => app(YouTubeFollowerCollector::class), + Platform::TikTok => app(TikTokFollowerCollector::class), + Platform::Bluesky => app(BlueskyFollowerCollector::class), + Platform::Mastodon => app(MastodonFollowerCollector::class), + default => throw AnalyticsCollectionException::unsupported("{$platform->value} follower analytics is excluded"), + }; + } +} diff --git a/app/Services/Analytics/Collectors/Followers/InstagramFollowerCollector.php b/app/Services/Analytics/Collectors/Followers/InstagramFollowerCollector.php new file mode 100644 index 000000000..01190000c --- /dev/null +++ b/app/Services/Analytics/Collectors/Followers/InstagramFollowerCollector.php @@ -0,0 +1,24 @@ +get( + $account, + "{$account->platform->instagramGraphBaseUrl()}/{$account->platform_user_id}", + ['fields' => 'followers_count'], + meta: true, + ); + + return $this->observation($date, data_get($response->json(), 'followers_count')); + } +} diff --git a/app/Services/Analytics/Collectors/Followers/MastodonFollowerCollector.php b/app/Services/Analytics/Collectors/Followers/MastodonFollowerCollector.php new file mode 100644 index 000000000..304135ced --- /dev/null +++ b/app/Services/Analytics/Collectors/Followers/MastodonFollowerCollector.php @@ -0,0 +1,24 @@ +meta, + 'instance', + config('trypost.platforms.mastodon.default_instance'), + ), '/'); + $response = $this->get($account, "{$instance}/api/v1/accounts/{$account->platform_user_id}"); + + return $this->observation($date, data_get($response->json(), 'followers_count')); + } +} diff --git a/app/Services/Analytics/Collectors/Followers/PinterestFollowerCollector.php b/app/Services/Analytics/Collectors/Followers/PinterestFollowerCollector.php new file mode 100644 index 000000000..f67a6b14a --- /dev/null +++ b/app/Services/Analytics/Collectors/Followers/PinterestFollowerCollector.php @@ -0,0 +1,19 @@ +get($account, config('trypost.platforms.pinterest.api').'/user_account'); + + return $this->observation($date, data_get($response->json(), 'follower_count')); + } +} diff --git a/app/Services/Analytics/Collectors/Followers/ThreadsFollowerCollector.php b/app/Services/Analytics/Collectors/Followers/ThreadsFollowerCollector.php new file mode 100644 index 000000000..028622af2 --- /dev/null +++ b/app/Services/Analytics/Collectors/Followers/ThreadsFollowerCollector.php @@ -0,0 +1,27 @@ +get( + $account, + config('trypost.platforms.threads.graph_api')."/{$account->platform_user_id}/threads_insights", + ['metric' => 'followers_count'], + meta: true, + ); + + $metric = collect(data_get($response->json(), 'data', [])) + ->firstWhere('name', 'followers_count'); + + return $this->observation($date, data_get($metric, 'total_value.value')); + } +} diff --git a/app/Services/Analytics/Collectors/Followers/TikTokFollowerCollector.php b/app/Services/Analytics/Collectors/Followers/TikTokFollowerCollector.php new file mode 100644 index 000000000..99b3cac5d --- /dev/null +++ b/app/Services/Analytics/Collectors/Followers/TikTokFollowerCollector.php @@ -0,0 +1,23 @@ +get( + $account, + config('trypost.platforms.tiktok.api').'/user/info/', + ['fields' => 'follower_count'], + ); + + return $this->observation($date, data_get($response->json(), 'data.user.follower_count')); + } +} diff --git a/app/Services/Analytics/Collectors/Followers/XFollowerCollector.php b/app/Services/Analytics/Collectors/Followers/XFollowerCollector.php new file mode 100644 index 000000000..59f1b287d --- /dev/null +++ b/app/Services/Analytics/Collectors/Followers/XFollowerCollector.php @@ -0,0 +1,23 @@ +get( + $account, + config('trypost.platforms.x.api')."/users/{$account->platform_user_id}", + ['user.fields' => 'public_metrics'], + ); + + return $this->observation($date, data_get($response->json(), 'data.public_metrics.followers_count')); + } +} diff --git a/app/Services/Analytics/Collectors/Followers/YouTubeFollowerCollector.php b/app/Services/Analytics/Collectors/Followers/YouTubeFollowerCollector.php new file mode 100644 index 000000000..436654289 --- /dev/null +++ b/app/Services/Analytics/Collectors/Followers/YouTubeFollowerCollector.php @@ -0,0 +1,41 @@ +get( + $account, + config('trypost.platforms.youtube.data_api').'/channels', + ['part' => 'statistics', 'id' => $account->platform_user_id], + ); + $statistics = data_get($response->json(), 'items.0.statistics'); + + if (data_get($statistics, 'hiddenSubscriberCount') === true) { + return new AccountDailyObservation( + date: $date, + followers: null, + provenance: ObservationProvenance::Actual, + precision: MetricPrecision::Approximate, + providerObservedAt: CarbonImmutable::now('UTC'), + collectedAt: CarbonImmutable::now('UTC'), + ); + } + + return $this->observation( + $date, + data_get($statistics, 'subscriberCount'), + MetricPrecision::Approximate, + ); + } +} diff --git a/app/Services/Analytics/Collectors/Metrics/AbstractMetaPublicationMetricsCollector.php b/app/Services/Analytics/Collectors/Metrics/AbstractMetaPublicationMetricsCollector.php new file mode 100644 index 000000000..e15d279a0 --- /dev/null +++ b/app/Services/Analytics/Collectors/Metrics/AbstractMetaPublicationMetricsCollector.php @@ -0,0 +1,16 @@ +socialAccount; + + if (! $account) { + throw AnalyticsCollectionException::unsupported('Publication has no live social account.'); + } + + return $account; + } + + /** @param list $metrics */ + protected function observation(CarbonImmutable $date, array $metrics): PublicationMetricObservation + { + if ($metrics === []) { + throw AnalyticsCollectionException::malformed('Publication metrics response contained no supported measurements.'); + } + + return new PublicationMetricObservation($date, $metrics, collectedAt: CarbonImmutable::now('UTC')); + } + + protected function count( + MetricKey $key, + array $source, + string $field, + MetricTimeBasis $timeBasis = MetricTimeBasis::Lifetime, + ): ?MetricValue { + $value = data_get($source, $field); + + if (! is_numeric($value)) { + return null; + } + + return new MetricValue( + key: $key, + value: (int) $value, + unit: MetricUnit::Count, + timeBasis: $timeBasis, + precision: MetricPrecision::Exact, + availability: MetricAvailability::Available, + providerMetric: $field, + ); + } + + protected function decimal( + MetricKey $key, + array $source, + string $field, + MetricUnit $unit, + float $multiplier = 1, + MetricTimeBasis $timeBasis = MetricTimeBasis::Lifetime, + ): ?MetricValue { + $value = data_get($source, $field); + + if (! is_numeric($value)) { + return null; + } + + return new MetricValue( + key: $key, + value: $unit === MetricUnit::Milliseconds + ? (int) round((float) $value * $multiplier) + : (float) $value * $multiplier, + unit: $unit, + timeBasis: $timeBasis, + precision: MetricPrecision::Exact, + availability: MetricAvailability::Available, + providerMetric: $field, + ); + } + + /** @return array */ + protected function insights(array $data): array + { + $values = []; + + foreach ($data as $item) { + $name = data_get($item, 'name'); + + if (! is_array($item) || ! is_string($name)) { + continue; + } + + $value = data_get($item, 'total_value.value') ?? data_get($item, 'values.0.value'); + + if (is_numeric($value)) { + $values[$name] = $value + 0; + } elseif (is_array($value) && $value !== []) { + $numbers = array_filter($value, 'is_numeric'); + + if (count($numbers) === count($value)) { + $values[$name] = array_sum($numbers); + } + } + } + + return $values; + } + + /** @param list $metrics */ + protected function withEngagements(array $metrics): array + { + foreach ($metrics as $metric) { + if ($metric->key === MetricKey::Engagements) { + return $metrics; + } + + if ($metric->key === MetricKey::TotalInteractions) { + $metrics[] = new MetricValue( + key: MetricKey::Engagements, + value: $metric->value, + unit: MetricUnit::Count, + timeBasis: $metric->timeBasis, + precision: $metric->precision, + availability: $metric->availability, + providerMetric: $metric->providerMetric, + ); + + return $metrics; + } + } + + $interactionKeys = [ + MetricKey::Reactions, MetricKey::Comments, MetricKey::Shares, + MetricKey::Saves, MetricKey::Quotes, MetricKey::Bookmarks, + MetricKey::Clicks, MetricKey::LinkClicks, + ]; + $present = array_filter($metrics, fn (MetricValue $metric): bool => in_array($metric->key, $interactionKeys, true)); + + if ($present !== []) { + $metrics[] = new MetricValue( + key: MetricKey::Engagements, + value: array_sum(array_map(fn (MetricValue $metric): int => (int) $metric->value, $present)), + unit: MetricUnit::Count, + timeBasis: MetricTimeBasis::Lifetime, + precision: MetricPrecision::Exact, + availability: MetricAvailability::Available, + providerMetric: 'derived_interactions', + ); + } + + return $metrics; + } + + /** @param array $metrics @return list */ + protected function present(array $metrics): array + { + return array_values(array_filter($metrics, fn (?MetricValue $metric): bool => $metric !== null)); + } +} diff --git a/app/Services/Analytics/Collectors/Metrics/BlueskyPublicationMetricsCollector.php b/app/Services/Analytics/Collectors/Metrics/BlueskyPublicationMetricsCollector.php new file mode 100644 index 000000000..92f0c00be --- /dev/null +++ b/app/Services/Analytics/Collectors/Metrics/BlueskyPublicationMetricsCollector.php @@ -0,0 +1,34 @@ +account($publication); + $uri = "at://{$account->platform_user_id}/".BlueskyLexicon::FEED_POST."/{$publication->remote_id}"; + $response = $this->get($account, + rtrim((string) config('trypost.platforms.bluesky.public_appview'), '/').'/xrpc/'.BlueskyLexicon::GET_POSTS, + ['uris' => [$uri]], + authenticated: false, + ); + + $post = (array) $response->json('posts.0', []); + + return $this->observation($date, $this->withEngagements($this->present([ + $this->count(MetricKey::Reactions, $post, 'likeCount'), + $this->count(MetricKey::Comments, $post, 'replyCount'), + $this->count(MetricKey::Shares, $post, 'repostCount'), + $this->count(MetricKey::Quotes, $post, 'quoteCount'), + ]))); + } +} diff --git a/app/Services/Analytics/Collectors/Metrics/FacebookPublicationMetricsCollector.php b/app/Services/Analytics/Collectors/Metrics/FacebookPublicationMetricsCollector.php new file mode 100644 index 000000000..c1333dad2 --- /dev/null +++ b/app/Services/Analytics/Collectors/Metrics/FacebookPublicationMetricsCollector.php @@ -0,0 +1,80 @@ +account($publication); + $isStory = $publication->content_type === PublicationContentType::Story; + $videoId = data_get($publication->provider_metadata, 'video_id'); + $isVideo = ! $isStory && (filled($videoId) || ! str_contains($publication->remote_id, '_')); + $insightsId = $isVideo && filled($videoId) ? $videoId : $publication->remote_id; + $edge = $isVideo ? 'video_insights' : 'insights'; + $fields = match (true) { + $isStory => ['page_story_impressions_by_story_id', 'page_story_impressions_by_story_id_unique', 'story_interaction', 'pages_fb_story_thread_lightweight_reactions', 'pages_fb_story_replies', 'pages_fb_story_shares'], + $isVideo => ['fb_reels_total_plays', 'post_video_likes_by_reaction_type', 'post_video_social_actions'], + default => ['post_media_view', 'post_total_media_view_unique', 'post_reactions_like_total', 'post_clicks'], + }; + $response = $this->get($account, + rtrim((string) config('trypost.platforms.facebook.graph_api'), '/')."/{$insightsId}/{$edge}", + ['metric' => implode(',', $fields), 'period' => 'lifetime', 'access_token' => $account->access_token], + ); + $items = $response->json('data'); + + if (! is_array($items)) { + throw AnalyticsCollectionException::malformed('Facebook insights response lacks data.'); + } + + $values = $this->insights($items); + + if (! $isStory) { + $details = $this->get($account, + rtrim((string) config('trypost.platforms.facebook.graph_api'), '/')."/{$publication->remote_id}", + [ + 'fields' => 'reactions.limit(0).summary(true),comments.limit(0).summary(true),shares', + 'access_token' => $account->access_token, + ], + )->json(); + + if (is_array($details)) { + foreach ([ + 'reactions.summary.total_count' => 'reactions_count', + 'comments.summary.total_count' => 'comments_count', + 'shares.count' => 'shares_count', + ] as $source => $target) { + $value = data_get($details, $source); + + if (is_numeric($value)) { + $values[$target] = (int) $value; + } + } + } + } + + return $this->observation($date, $this->withEngagements($this->present([ + $this->count(MetricKey::Impressions, $values, $isStory ? 'page_story_impressions_by_story_id' : 'post_media_view'), + $this->count(MetricKey::Reach, $values, $isStory ? 'page_story_impressions_by_story_id_unique' : 'post_total_media_view_unique'), + $this->count(MetricKey::Views, $values, 'fb_reels_total_plays'), + $this->count(MetricKey::Reactions, $values, 'reactions_count') ?? $this->count(MetricKey::Reactions, $values, match (true) { + $isStory => 'pages_fb_story_thread_lightweight_reactions', + $isVideo => 'post_video_likes_by_reaction_type', + default => 'post_reactions_like_total', + }), + $this->count(MetricKey::Comments, $values, 'comments_count') ?? $this->count(MetricKey::Comments, $values, 'pages_fb_story_replies'), + $this->count(MetricKey::Shares, $values, 'shares_count') ?? $this->count(MetricKey::Shares, $values, 'pages_fb_story_shares'), + $this->count(MetricKey::Clicks, $values, 'post_clicks'), + $this->count(MetricKey::TotalInteractions, $values, $isStory ? 'story_interaction' : 'post_video_social_actions'), + ]))); + } +} diff --git a/app/Services/Analytics/Collectors/Metrics/InstagramPublicationMetricsCollector.php b/app/Services/Analytics/Collectors/Metrics/InstagramPublicationMetricsCollector.php new file mode 100644 index 000000000..8a4576fa4 --- /dev/null +++ b/app/Services/Analytics/Collectors/Metrics/InstagramPublicationMetricsCollector.php @@ -0,0 +1,88 @@ +account($publication); + $isStory = $publication->content_type === PublicationContentType::Story; + $isReel = $publication->content_type === PublicationContentType::Reel; + $fields = $isStory + ? ['reach', 'views', 'replies'] + : ['reach', 'views', 'likes', 'comments', 'shares', 'saved']; + $url = $account->platform->instagramGraphBaseUrl()."/{$publication->remote_id}/insights"; + $response = $this->get($account, $url, ['metric' => implode(',', $fields), 'access_token' => $account->access_token]); + $items = $response->json('data'); + + if (! is_array($items)) { + throw AnalyticsCollectionException::malformed('Instagram insights response lacks data.'); + } + + $values = $this->insights($items); + $extras = $isStory + ? $this->optionalInsights($account, $url, ['navigation', 'taps_forward', 'taps_back', 'exits']) + : $this->optionalInsights($account, $url, ['total_interactions', 'reposts', 'follows', 'profile_visits', 'profile_activity']); + $values = array_merge($values, $extras); + $metrics = $this->present([ + $this->count(MetricKey::Reach, $values, 'reach'), + $this->count(MetricKey::Views, $values, 'views'), + $this->count(MetricKey::Reactions, $values, 'likes'), + $this->count(MetricKey::Comments, $values, $isStory ? 'replies' : 'comments'), + $this->count(MetricKey::Shares, $values, 'shares'), + $this->count(MetricKey::Saves, $values, 'saved'), + $this->count(MetricKey::Reposts, $values, 'reposts'), + $this->count(MetricKey::TotalInteractions, $values, 'total_interactions'), + $this->count(MetricKey::Follows, $values, 'follows'), + $this->count(MetricKey::ProfileVisits, $values, 'profile_visits'), + $this->count(MetricKey::ProfileActivity, $values, 'profile_activity'), + $this->count(MetricKey::StoryNavigation, $values, 'navigation'), + $this->count(MetricKey::StoryTapsForward, $values, 'taps_forward'), + $this->count(MetricKey::StoryTapsBack, $values, 'taps_back'), + $this->count(MetricKey::StoryExits, $values, 'exits'), + ]); + + if ($isReel) { + $reelValues = $this->optionalInsights($account, $url, ['ig_reels_video_view_total_time', 'ig_reels_avg_watch_time']); + $metrics = array_merge($metrics, $this->present([ + $this->decimal(MetricKey::WatchTimeMilliseconds, $reelValues, 'ig_reels_video_view_total_time', MetricUnit::Milliseconds), + $this->decimal(MetricKey::AverageWatchTimeMilliseconds, $reelValues, 'ig_reels_avg_watch_time', MetricUnit::Milliseconds), + ])); + } + + return $this->observation($date, $this->withEngagements($metrics)); + } + + /** @param list $fields @return array */ + private function optionalInsights(SocialAccount $account, string $url, array $fields): array + { + try { + $response = $this->get($account, $url, [ + 'metric' => implode(',', $fields), + 'access_token' => $account->access_token, + ]); + } catch (AnalyticsCollectionException $exception) { + if (! in_array($exception->category, ['permission', 'malformed'], true)) { + throw $exception; + } + + return []; + } + + $data = $response->json('data'); + + return is_array($data) ? $this->insights($data) : []; + } +} diff --git a/app/Services/Analytics/Collectors/Metrics/MastodonPublicationMetricsCollector.php b/app/Services/Analytics/Collectors/Metrics/MastodonPublicationMetricsCollector.php new file mode 100644 index 000000000..0ff0a9dd4 --- /dev/null +++ b/app/Services/Analytics/Collectors/Metrics/MastodonPublicationMetricsCollector.php @@ -0,0 +1,28 @@ +account($publication); + $instance = rtrim((string) data_get($account->meta, 'instance', config('trypost.platforms.mastodon.default_instance')), '/'); + $response = $this->get($account, "{$instance}/api/v1/statuses/{$publication->remote_id}"); + + $status = (array) $response->json(); + + return $this->observation($date, $this->withEngagements($this->present([ + $this->count(MetricKey::Reactions, $status, 'favourites_count'), + $this->count(MetricKey::Comments, $status, 'replies_count'), + $this->count(MetricKey::Shares, $status, 'reblogs_count'), + ]))); + } +} diff --git a/app/Services/Analytics/Collectors/Metrics/PinterestPublicationMetricsCollector.php b/app/Services/Analytics/Collectors/Metrics/PinterestPublicationMetricsCollector.php new file mode 100644 index 000000000..6792a0e34 --- /dev/null +++ b/app/Services/Analytics/Collectors/Metrics/PinterestPublicationMetricsCollector.php @@ -0,0 +1,72 @@ +account($publication); + $isVideo = in_array($publication->content_type, [PublicationContentType::Video, PublicationContentType::Short], true); + $fields = ['IMPRESSION', 'SAVE', 'PIN_CLICK', 'OUTBOUND_CLICK', 'ENGAGEMENT', 'ENGAGEMENT_RATE', 'SAVE_RATE', 'PIN_CLICK_RATE', 'OUTBOUND_CLICK_RATE']; + + if ($isVideo) { + $fields = array_merge($fields, ['VIDEO_MRC_VIEW', 'VIDEO_AVG_WATCH_TIME', 'VIDEO_10S_VIEW', 'QUARTILE_95_PERCENT_VIEW', 'VIDEO_V50_WATCH_TIME']); + } + + $response = $this->get($account, + rtrim((string) config('trypost.platforms.pinterest.api'), '/')."/pins/{$publication->remote_id}/analytics", + [ + 'start_date' => $date->subDays(89)->toDateString(), + 'end_date' => $date->toDateString(), + 'metric_types' => implode(',', $fields), + ], + ); + $values = $response->json('all.summary_metrics'); + + if (! is_array($values)) { + throw AnalyticsCollectionException::malformed('Pinterest Pin analytics response lacks summary metrics.'); + } + + $basis = MetricTimeBasis::Rolling90Days; + $metrics = $this->present([ + $this->count(MetricKey::Impressions, $values, 'IMPRESSION', $basis), + $this->count(MetricKey::Saves, $values, 'SAVE', $basis), + $this->count(MetricKey::PinClicks, $values, 'PIN_CLICK', $basis), + $this->count(MetricKey::OutboundClicks, $values, 'OUTBOUND_CLICK', $basis), + $this->count(MetricKey::Engagements, $values, 'ENGAGEMENT', $basis), + $this->decimal(MetricKey::EngagementRate, $values, 'ENGAGEMENT_RATE', MetricUnit::Percent, 100, $basis), + $this->decimal(MetricKey::SaveRate, $values, 'SAVE_RATE', MetricUnit::Percent, 100, $basis), + $this->decimal(MetricKey::PinClickRate, $values, 'PIN_CLICK_RATE', MetricUnit::Percent, 100, $basis), + $this->decimal(MetricKey::OutboundClickRate, $values, 'OUTBOUND_CLICK_RATE', MetricUnit::Percent, 100, $basis), + $this->count(MetricKey::VideoViews, $values, 'VIDEO_MRC_VIEW', $basis), + $this->decimal(MetricKey::AverageVideoPlayTimeMilliseconds, $values, 'VIDEO_AVG_WATCH_TIME', MetricUnit::Milliseconds, timeBasis: $basis), + $this->count(MetricKey::VideoViews10Seconds, $values, 'VIDEO_10S_VIEW', $basis), + $this->count(MetricKey::VideoViews95Percent, $values, 'QUARTILE_95_PERCENT_VIEW', $basis), + $this->decimal(MetricKey::TotalPlayTimeMilliseconds, $values, 'VIDEO_V50_WATCH_TIME', MetricUnit::Milliseconds, timeBasis: $basis), + ]); + $details = $this->get($account, + rtrim((string) config('trypost.platforms.pinterest.api'), '/')."/pins/{$publication->remote_id}", + ['pin_metrics' => 'true'], + )->json(); + $lifetime = (array) (data_get($details, 'pin_metrics.all.lifetime_metrics') + ?? data_get($details, 'pin_metrics.lifetime_metrics', [])); + $metrics = array_merge($metrics, $this->present([ + $this->count(MetricKey::Comments, $lifetime, 'TOTAL_COMMENTS'), + $this->count(MetricKey::Reactions, $lifetime, 'TOTAL_REACTIONS'), + ])); + + return $this->observation($date, $metrics); + } +} diff --git a/app/Services/Analytics/Collectors/Metrics/PublicationMetricsCollectorFactory.php b/app/Services/Analytics/Collectors/Metrics/PublicationMetricsCollectorFactory.php new file mode 100644 index 000000000..2953f833c --- /dev/null +++ b/app/Services/Analytics/Collectors/Metrics/PublicationMetricsCollectorFactory.php @@ -0,0 +1,27 @@ + app(InstagramPublicationMetricsCollector::class), + Platform::Facebook => app(FacebookPublicationMetricsCollector::class), + Platform::Threads => app(ThreadsPublicationMetricsCollector::class), + Platform::X => app(XPublicationMetricsCollector::class), + Platform::Pinterest => app(PinterestPublicationMetricsCollector::class), + Platform::YouTube => app(YouTubePublicationMetricsCollector::class), + Platform::TikTok => app(TikTokPublicationMetricsCollector::class), + Platform::Bluesky => app(BlueskyPublicationMetricsCollector::class), + Platform::Mastodon => app(MastodonPublicationMetricsCollector::class), + default => throw AnalyticsCollectionException::unsupported("{$platform->value} publication metrics are excluded"), + }; + } +} diff --git a/app/Services/Analytics/Collectors/Metrics/ThreadsPublicationMetricsCollector.php b/app/Services/Analytics/Collectors/Metrics/ThreadsPublicationMetricsCollector.php new file mode 100644 index 000000000..d5f56194d --- /dev/null +++ b/app/Services/Analytics/Collectors/Metrics/ThreadsPublicationMetricsCollector.php @@ -0,0 +1,38 @@ +account($publication); + $response = $this->get($account, + rtrim((string) config('trypost.platforms.threads.graph_api'), '/')."/{$publication->remote_id}/insights", + ['metric' => 'views,likes,replies,reposts,quotes'], + ); + $items = $response->json('data'); + + if (! is_array($items)) { + throw AnalyticsCollectionException::malformed('Threads insights response lacks data.'); + } + + $values = $this->insights($items); + + return $this->observation($date, $this->withEngagements($this->present([ + $this->count(MetricKey::Views, $values, 'views'), + $this->count(MetricKey::Reactions, $values, 'likes'), + $this->count(MetricKey::Comments, $values, 'replies'), + $this->count(MetricKey::Shares, $values, 'reposts'), + $this->count(MetricKey::Quotes, $values, 'quotes'), + ]))); + } +} diff --git a/app/Services/Analytics/Collectors/Metrics/TikTokPublicationMetricsCollector.php b/app/Services/Analytics/Collectors/Metrics/TikTokPublicationMetricsCollector.php new file mode 100644 index 000000000..023511a38 --- /dev/null +++ b/app/Services/Analytics/Collectors/Metrics/TikTokPublicationMetricsCollector.php @@ -0,0 +1,66 @@ +account($publication); + $videoId = $this->publicVideoId($publication); + + $response = $this->post($account, + rtrim((string) config('trypost.platforms.tiktok.api'), '/').'/video/query/?fields=id,view_count,like_count,comment_count,share_count', + ['filters' => ['video_ids' => [$videoId]]], + ); + $errorCode = $response->json('error.code'); + + if (is_string($errorCode) && ! in_array($errorCode, ['', 'ok'], true)) { + throw new AnalyticsCollectionException( + $errorCode === 'rate_limit_exceeded' ? 'rate_limited' : 'permission', + 'TikTok video metrics query rejected the request.', + ); + } + + $video = collect((array) $response->json('data.videos', [])) + ->first(fn (mixed $item): bool => is_array($item) && (string) data_get($item, 'id', '') === $videoId); + + if (! is_array($video)) { + throw AnalyticsCollectionException::malformed('TikTok video query did not return the requested video.'); + } + + return $this->observation($date, $this->withEngagements($this->present([ + $this->count(MetricKey::Views, $video, 'view_count'), + $this->count(MetricKey::Reactions, $video, 'like_count'), + $this->count(MetricKey::Comments, $video, 'comment_count'), + $this->count(MetricKey::Shares, $video, 'share_count'), + ]))); + } + + public function publicVideoId(AnalyticsPublication $publication): string + { + $videoId = $publication->remote_id; + + if (! ctype_digit($videoId)) { + $status = $this->post($this->account($publication), + rtrim((string) config('trypost.platforms.tiktok.api'), '/').'/post/publish/status/fetch/', + ['publish_id' => $videoId], + ); + $videoId = (string) $status->json('data.publicaly_available_post_id.0', ''); + + if (! ctype_digit($videoId)) { + throw new AnalyticsCollectionException('delayed', 'TikTok publication has no public video id yet.'); + } + } + + return $videoId; + } +} diff --git a/app/Services/Analytics/Collectors/Metrics/XPublicationMetricsCollector.php b/app/Services/Analytics/Collectors/Metrics/XPublicationMetricsCollector.php new file mode 100644 index 000000000..541a9e132 --- /dev/null +++ b/app/Services/Analytics/Collectors/Metrics/XPublicationMetricsCollector.php @@ -0,0 +1,47 @@ +account($publication); + $fields = ['public_metrics']; + + if ($publication->provider_published_at->greaterThan(CarbonImmutable::now('UTC')->subDays(30))) { + $fields[] = 'non_public_metrics'; + } + + $response = $this->get($account, + rtrim((string) config('trypost.platforms.x.api'), '/')."/tweets/{$publication->remote_id}", + ['tweet.fields' => implode(',', $fields)], + ); + $tweet = $response->json('data'); + + if (! is_array($tweet)) { + throw AnalyticsCollectionException::malformed('X post response lacks data.'); + } + + $public = (array) data_get($tweet, 'public_metrics', []); + $private = (array) data_get($tweet, 'non_public_metrics', []); + + return $this->observation($date, $this->withEngagements($this->present([ + $this->count(MetricKey::Impressions, $public, 'impression_count') ?? $this->count(MetricKey::Impressions, $private, 'impression_count'), + $this->count(MetricKey::Reactions, $public, 'like_count'), + $this->count(MetricKey::Comments, $public, 'reply_count'), + $this->count(MetricKey::Shares, $public, 'retweet_count'), + $this->count(MetricKey::Quotes, $public, 'quote_count'), + $this->count(MetricKey::Bookmarks, $public, 'bookmark_count'), + $this->count(MetricKey::LinkClicks, $private, 'url_link_clicks'), + ]))); + } +} diff --git a/app/Services/Analytics/Collectors/Metrics/YouTubePublicationMetricsCollector.php b/app/Services/Analytics/Collectors/Metrics/YouTubePublicationMetricsCollector.php new file mode 100644 index 000000000..b455f7df4 --- /dev/null +++ b/app/Services/Analytics/Collectors/Metrics/YouTubePublicationMetricsCollector.php @@ -0,0 +1,91 @@ +account($publication); + $response = $this->get($account, + rtrim((string) config('trypost.platforms.youtube.analytics_api'), '/').'/reports', + [ + 'ids' => 'channel==MINE', + 'startDate' => $publication->provider_published_at->toDateString(), + 'endDate' => $date->toDateString(), + 'metrics' => 'views,engagedViews,estimatedMinutesWatched,averageViewDuration,averageViewPercentage,likes,comments,shares,subscribersGained,subscribersLost', + 'filters' => "video=={$publication->remote_id}", + ], + ); + $headers = $response->json('columnHeaders'); + $row = $response->json('rows.0'); + + if (! is_array($headers)) { + throw AnalyticsCollectionException::malformed('YouTube Analytics response lacks column headers.'); + } + + if ($row === null) { + return $this->currentVideoStatistics($account, $publication, $date); + } + + $values = []; + + if (is_array($row)) { + foreach ($headers as $index => $header) { + if (is_string(data_get($header, 'name')) && array_key_exists($index, $row)) { + $values[data_get($header, 'name')] = data_get($row, $index); + } + } + } + + return $this->observation($date, $this->withEngagements($this->present([ + $this->count(MetricKey::Views, $values, 'views'), + $this->count(MetricKey::EngagedViews, $values, 'engagedViews'), + $this->decimal(MetricKey::WatchTimeMilliseconds, $values, 'estimatedMinutesWatched', MetricUnit::Milliseconds, 60000), + $this->decimal(MetricKey::AverageWatchTimeMilliseconds, $values, 'averageViewDuration', MetricUnit::Milliseconds, 1000), + $this->decimal(MetricKey::AveragePercentageViewed, $values, 'averageViewPercentage', MetricUnit::Percent), + $this->count(MetricKey::Reactions, $values, 'likes'), + $this->count(MetricKey::Comments, $values, 'comments'), + $this->count(MetricKey::Shares, $values, 'shares'), + $this->count(MetricKey::SubscribersGained, $values, 'subscribersGained'), + $this->count(MetricKey::SubscribersLost, $values, 'subscribersLost'), + ]))); + } + + private function currentVideoStatistics( + SocialAccount $account, + AnalyticsPublication $publication, + CarbonImmutable $date, + ): PublicationMetricObservation { + $response = $this->get($account, + rtrim((string) config('trypost.platforms.youtube.data_api'), '/').'/videos', + [ + 'part' => 'statistics', + 'id' => $publication->remote_id, + ], + ); + $video = $response->json('items.0'); + + if (! is_array($video) || data_get($video, 'id') !== $publication->remote_id) { + throw AnalyticsCollectionException::malformed('YouTube video statistics response does not match the publication.'); + } + + $statistics = (array) data_get($video, 'statistics', []); + + return $this->observation($date, $this->withEngagements($this->present([ + $this->count(MetricKey::Views, $statistics, 'viewCount'), + $this->count(MetricKey::Reactions, $statistics, 'likeCount'), + $this->count(MetricKey::Comments, $statistics, 'commentCount'), + ]))); + } +} diff --git a/app/Services/Analytics/Collectors/Publications/AbstractApiPublicationCollector.php b/app/Services/Analytics/Collectors/Publications/AbstractApiPublicationCollector.php new file mode 100644 index 000000000..8782ad573 --- /dev/null +++ b/app/Services/Analytics/Collectors/Publications/AbstractApiPublicationCollector.php @@ -0,0 +1,84 @@ + $query */ + protected function get( + SocialAccount $account, + string $url, + array $query = [], + bool $authenticated = true, + ): Response { + $response = $this->client($account, $authenticated) + ->get($url, array_filter($query, fn (mixed $value): bool => $value !== null && $value !== '')); + + if ((filled(data_get($query, 'pageToken')) + || filled(data_get($query, 'pagination_token')) + || filled(data_get($query, 'bookmark'))) + && InvalidPublicationCursor::matches($response)) { + throw new AnalyticsCollectionException('invalid_cursor', 'publication history cursor expired'); + } + + return $this->successfulResponse($response); + } + + /** @param array $payload */ + protected function post(SocialAccount $account, string $url, array $payload, bool $hasCursor = false): Response + { + $response = $this->client($account, true) + ->asJson() + ->post($url, $payload); + + if ($hasCursor && InvalidPublicationCursor::matches($response)) { + throw new AnalyticsCollectionException('invalid_cursor', 'publication history cursor expired'); + } + + return $this->successfulResponse($response); + } + + protected function publishedAt(mixed $value, bool $timestamp = false): ?CarbonImmutable + { + if ($timestamp && is_numeric($value)) { + return CarbonImmutable::createFromTimestampUTC((int) $value); + } + + if (! is_string($value) || $value === '') { + return null; + } + + try { + return CarbonImmutable::parse($value)->utc(); + } catch (Throwable) { + return null; + } + } + + protected function successfulResponse(Response $response): Response + { + if ($response->successful()) { + return $response; + } + + throw AnalyticsCollectionException::fromResponse($response, 'publication history collection'); + } + + private function client(SocialAccount $account, bool $authenticated): PendingRequest + { + $client = Http::acceptJson()->timeout(120); + + return $authenticated ? $client->withToken($account->access_token) : $client; + } +} diff --git a/app/Services/Analytics/Collectors/Publications/AbstractMetaPublicationCollector.php b/app/Services/Analytics/Collectors/Publications/AbstractMetaPublicationCollector.php new file mode 100644 index 000000000..23eb75a01 --- /dev/null +++ b/app/Services/Analytics/Collectors/Publications/AbstractMetaPublicationCollector.php @@ -0,0 +1,71 @@ + $query + */ + protected function get(SocialAccount $account, string $url, array $query = [], bool $authenticated = true): Response + { + $response = Http::acceptJson() + ->withToken($account->access_token) + ->timeout(120) + ->get($url, array_filter($query, fn (mixed $value): bool => $value !== null && $value !== '')); + + if (filled(data_get($query, 'after')) && InvalidPublicationCursor::matches($response)) { + throw new AnalyticsCollectionException('invalid_cursor', 'publication history cursor expired'); + } + + return MetaAnalyticsResponse::successful($response, 'publication history collection'); + } + + /** + * @param list $publications + */ + protected function result( + array $publications, + Response $response, + bool $crossedCutoff, + bool $providerLimited = false, + ): PublicationPage { + $nextCursor = data_get($response->json(), 'paging.next') + ? data_get($response->json(), 'paging.cursors.after') + : null; + + return new PublicationPage( + publications: $publications, + nextCursor: $crossedCutoff ? null : (is_string($nextCursor) ? $nextCursor : null), + providerExhausted: $crossedCutoff || ! is_string($nextCursor), + providerLimited: $providerLimited, + ); + } + + protected function publishedAt(mixed $value, bool $timestamp = false): ?CarbonImmutable + { + if (! is_string($value) || $value === '') { + return null; + } + + return CarbonImmutable::parse($value)->utc(); + } + + /** @return array|null */ + protected function preview(?string $url): ?array + { + return filled($url) ? ['thumbnail_url' => $url] : null; + } +} diff --git a/app/Services/Analytics/Collectors/Publications/AbstractPublicationHistoryCollector.php b/app/Services/Analytics/Collectors/Publications/AbstractPublicationHistoryCollector.php new file mode 100644 index 000000000..6c93c430a --- /dev/null +++ b/app/Services/Analytics/Collectors/Publications/AbstractPublicationHistoryCollector.php @@ -0,0 +1,18 @@ +meta, + 'service', + config('trypost.platforms.bluesky.default_service'), + ), '/'); + $response = $this->get( + $account, + "{$pds}/xrpc/".BlueskyLexicon::LIST_RECORDS, + [ + 'repo' => $account->platform_user_id, + 'collection' => BlueskyLexicon::FEED_POST, + 'limit' => self::PAGE_SIZE, + 'cursor' => $cursor, + 'reverse' => true, + ], + authenticated: false, + ); + $records = collect((array) $response->json('records', [])); + $hydrated = $this->hydrate($account, $records->pluck('uri')->filter()->values()); + $publications = []; + $crossedCutoff = false; + + foreach ($records as $record) { + $publishedAt = $this->publishedAt(data_get($record, 'value.createdAt')); + + if (! $publishedAt) { + continue; + } + + if ($publishedAt->lessThan($cutoff)) { + $crossedCutoff = true; + + break; + } + + $uri = (string) data_get($record, 'uri'); + $postId = basename($uri); + $view = $hydrated->get($uri); + + $publications[] = new DiscoveredPublication( + providerPostId: $postId, + publishedAt: $publishedAt, + contentType: $this->contentType((array) data_get($record, 'value.embed', [])), + providerContentType: data_get($record, 'value.embed.$type'), + permalink: filled($account->username) + ? "https://bsky.app/profile/{$account->username}/post/{$postId}" + : "https://bsky.app/profile/{$account->platform_user_id}/post/{$postId}", + excerpt: data_get($record, 'value.text'), + previewMetadata: $this->preview(is_array($view) ? $view : []), + providerMetadata: $this->providerMetadata(is_array($view) ? $view : []), + ); + } + + $nextCursor = data_get($response->json(), 'cursor'); + $hasNext = is_string($nextCursor) && $nextCursor !== '' && ! $crossedCutoff; + + return new PublicationPage($publications, $hasNext ? $nextCursor : null, ! $hasNext); + } + + /** + * @param Collection $uris + * @return Collection> + */ + private function hydrate(SocialAccount $account, Collection $uris): Collection + { + $appView = rtrim((string) config('trypost.platforms.bluesky.public_appview'), '/'); + $posts = collect(); + + foreach ($uris->chunk(self::HYDRATION_BATCH_SIZE) as $batch) { + $response = $this->get( + $account, + "{$appView}/xrpc/".BlueskyLexicon::GET_POSTS, + ['uris' => $batch->values()->all()], + authenticated: false, + ); + + $posts->push(...(array) $response->json('posts', [])); + } + + return $posts->filter(fn (mixed $post): bool => is_array($post))->keyBy('uri'); + } + + /** @param array $embed */ + private function contentType(array $embed): PublicationContentType + { + $type = (string) data_get($embed, '$type'); + $media = str_ends_with($type, 'recordWithMedia') ? (array) data_get($embed, 'media', []) : $embed; + $mediaType = (string) data_get($media, '$type'); + + return match (true) { + str_ends_with($mediaType, 'video') => PublicationContentType::Video, + str_ends_with($mediaType, 'gallery'), count((array) data_get($media, 'images', [])) > 1 => PublicationContentType::Carousel, + str_ends_with($mediaType, 'images') => PublicationContentType::Image, + str_ends_with($type, 'external') => PublicationContentType::Link, + default => PublicationContentType::Text, + }; + } + + /** @param array $view */ + private function preview(array $view): ?array + { + $thumbnail = data_get($view, 'embed.images.0.thumb') + ?: data_get($view, 'embed.thumbnail') + ?: data_get($view, 'embed.media.images.0.thumb') + ?: data_get($view, 'embed.media.thumbnail'); + + return filled($thumbnail) ? ['thumbnail_url' => $thumbnail] : null; + } + + /** @param array $view */ + private function providerMetadata(array $view): array + { + return [ + 'like_count' => (int) data_get($view, 'likeCount', 0), + 'repost_count' => (int) data_get($view, 'repostCount', 0), + 'reply_count' => (int) data_get($view, 'replyCount', 0), + 'quote_count' => (int) data_get($view, 'quoteCount', 0), + 'public_metrics_available' => $view !== [], + ]; + } +} diff --git a/app/Services/Analytics/Collectors/Publications/FacebookPublicationCollector.php b/app/Services/Analytics/Collectors/Publications/FacebookPublicationCollector.php new file mode 100644 index 000000000..8276f8d13 --- /dev/null +++ b/app/Services/Analytics/Collectors/Publications/FacebookPublicationCollector.php @@ -0,0 +1,113 @@ +get( + $account, + config('trypost.platforms.facebook.graph_api')."/{$account->platform_user_id}/published_posts", + ['fields' => self::FIELDS, 'limit' => self::PAGE_SIZE, 'after' => $cursor], + ); + $publications = []; + $crossedCutoff = false; + $providerLimited = false; + + foreach ((array) $response->json('data', []) as $row) { + $publishedAt = $this->publishedAt(data_get($row, 'created_time')); + + if (! $publishedAt) { + $providerLimited = true; + + continue; + } + + if ($publishedAt->lessThan($cutoff)) { + $crossedCutoff = true; + + break; + } + + $attachment = (array) data_get($row, 'attachments.data.0', []); + $previewUrl = data_get($attachment, 'media.image.src'); + $permalink = data_get($row, 'permalink_url'); + + $videoId = $this->isVideo($attachment) ? data_get($attachment, 'target.id') : null; + + if (filled($videoId)) { + $hydrated = $this->hydratePreview($account, (string) $videoId); + $previewUrl = data_get($hydrated, 'picture') ?: $previewUrl; + $permalink = $permalink ?: data_get($hydrated, 'permalink_url'); + } + + $publications[] = new DiscoveredPublication( + providerPostId: (string) data_get($row, 'id'), + publishedAt: $publishedAt, + contentType: $this->contentType($attachment), + providerContentType: data_get($attachment, 'type') ?: data_get($attachment, 'media_type') ?: data_get($row, 'status_type'), + permalink: $permalink, + excerpt: data_get($row, 'message'), + previewMetadata: $this->preview($previewUrl), + providerMetadata: filled($videoId) ? ['video_id' => (string) $videoId] : null, + ); + } + + return $this->result($publications, $response, $crossedCutoff, $providerLimited); + } + + /** @param array $attachment */ + private function contentType(array $attachment): PublicationContentType + { + $mediaType = strtolower((string) data_get($attachment, 'media_type')); + $type = strtolower((string) data_get($attachment, 'type')); + $url = strtolower((string) data_get($attachment, 'url')); + + return match (true) { + count((array) data_get($attachment, 'subattachments.data', [])) > 1 => PublicationContentType::Carousel, + str_contains($type, 'reel'), str_contains($url, '/reel') => PublicationContentType::Reel, + str_contains($mediaType, 'video'), str_contains($type, 'video') => PublicationContentType::Video, + str_contains($mediaType, 'photo'), str_contains($mediaType, 'image') => PublicationContentType::Image, + str_contains($type, 'share'), filled($url) => PublicationContentType::Link, + $attachment === [] => PublicationContentType::Text, + default => PublicationContentType::Unknown, + }; + } + + /** @param array $attachment */ + private function isVideo(array $attachment): bool + { + return in_array($this->contentType($attachment), [PublicationContentType::Video, PublicationContentType::Reel], true); + } + + /** @return array */ + private function hydratePreview(SocialAccount $account, string $videoId): array + { + try { + return (array) $this->get( + $account, + config('trypost.platforms.facebook.graph_api')."/{$videoId}", + ['fields' => 'picture,permalink_url'], + )->json(); + } catch (Throwable) { + return []; + } + } +} diff --git a/app/Services/Analytics/Collectors/Publications/InstagramPublicationCollector.php b/app/Services/Analytics/Collectors/Publications/InstagramPublicationCollector.php new file mode 100644 index 000000000..ff1073684 --- /dev/null +++ b/app/Services/Analytics/Collectors/Publications/InstagramPublicationCollector.php @@ -0,0 +1,81 @@ +platform === Platform::InstagramFacebook + ? 'id,caption,media_type,media_product_type,media_url,permalink,thumbnail_url,timestamp' + : 'id,caption,media_type,media_url,permalink,thumbnail_url,timestamp'; + $response = $this->get( + $account, + "{$account->platform->instagramGraphBaseUrl()}/{$account->platform_user_id}/media", + ['fields' => $fields, 'limit' => self::PAGE_SIZE, 'after' => $cursor], + ); + $publications = []; + $crossedCutoff = false; + $providerLimited = false; + + foreach ((array) $response->json('data', []) as $row) { + $publishedAt = $this->publishedAt(data_get($row, 'timestamp')); + + if (! $publishedAt) { + $providerLimited = true; + + continue; + } + + if ($publishedAt->lessThan($cutoff)) { + $crossedCutoff = true; + + break; + } + + $publications[] = new DiscoveredPublication( + providerPostId: (string) data_get($row, 'id'), + publishedAt: $publishedAt, + contentType: $this->contentType($row, $account->platform), + providerContentType: data_get($row, 'media_product_type') ?: data_get($row, 'media_type'), + permalink: data_get($row, 'permalink'), + excerpt: data_get($row, 'caption'), + previewMetadata: $this->preview(data_get($row, 'thumbnail_url') ?: data_get($row, 'media_url')), + providerMetadata: null, + ); + } + + return $this->result($publications, $response, $crossedCutoff, $providerLimited); + } + + /** @param array $row */ + private function contentType(array $row, Platform $platform): PublicationContentType + { + $mediaType = strtoupper((string) data_get($row, 'media_type')); + $productType = strtoupper((string) data_get($row, 'media_product_type')); + + return match (true) { + $productType === 'STORY' => PublicationContentType::Story, + $productType === 'REELS' => PublicationContentType::Reel, + $mediaType === 'CAROUSEL_ALBUM' => PublicationContentType::Carousel, + $mediaType === 'VIDEO' && $platform === Platform::Instagram => PublicationContentType::Reel, + $mediaType === 'VIDEO' => PublicationContentType::Video, + $mediaType === 'IMAGE' => PublicationContentType::Image, + default => PublicationContentType::Unknown, + }; + } +} diff --git a/app/Services/Analytics/Collectors/Publications/MastodonPublicationCollector.php b/app/Services/Analytics/Collectors/Publications/MastodonPublicationCollector.php new file mode 100644 index 000000000..f73d05810 --- /dev/null +++ b/app/Services/Analytics/Collectors/Publications/MastodonPublicationCollector.php @@ -0,0 +1,129 @@ +scopes ?? [])) > 0; + $instance = rtrim((string) data_get( + $account->meta, + 'instance', + config('trypost.platforms.mastodon.default_instance'), + ), '/'); + $response = $this->get( + $account, + "{$instance}/api/v1/accounts/{$account->platform_user_id}/statuses", + [ + 'limit' => self::PAGE_SIZE, + 'exclude_reblogs' => true, + 'max_id' => $cursor, + ], + authenticated: $hasPrivateHistoryScope, + ); + $rows = (array) $response->json(); + $publications = []; + $crossedCutoff = false; + + foreach ($rows as $row) { + $publishedAt = $this->publishedAt(data_get($row, 'created_at')); + + if (! $publishedAt) { + continue; + } + + if ($publishedAt->lessThan($cutoff)) { + $crossedCutoff = true; + + break; + } + + $attachments = (array) data_get($row, 'media_attachments', []); + $thumbnail = data_get($attachments, '0.preview_url') ?: data_get($attachments, '0.url'); + + $publications[] = new DiscoveredPublication( + providerPostId: (string) data_get($row, 'id'), + publishedAt: $publishedAt, + contentType: $this->contentType($row, $attachments), + providerContentType: data_get($attachments, '0.type') ?: (data_get($row, 'poll') ? 'poll' : 'status'), + permalink: data_get($row, 'url'), + excerpt: $this->plainText((string) data_get($row, 'content', '')), + previewMetadata: filled($thumbnail) ? ['thumbnail_url' => $thumbnail] : null, + providerMetadata: [ + 'visibility' => data_get($row, 'visibility'), + 'favourites_count' => (int) data_get($row, 'favourites_count', 0), + 'reblogs_count' => (int) data_get($row, 'reblogs_count', 0), + 'replies_count' => (int) data_get($row, 'replies_count', 0), + 'history_visibility' => $hasPrivateHistoryScope ? 'authorized' : 'public_only', + 'reconnect_required' => ! $hasPrivateHistoryScope, + ], + ); + } + + $nextCursor = $crossedCutoff ? null : $this->nextCursor($response, $rows); + + return new PublicationPage( + publications: $publications, + nextCursor: $nextCursor, + providerExhausted: $crossedCutoff || $nextCursor === null, + partialReason: $hasPrivateHistoryScope ? null : self::PARTIAL_REASON, + ); + } + + /** @param array $row @param array $attachments */ + private function contentType(array $row, array $attachments): PublicationContentType + { + $types = collect($attachments)->pluck('type'); + + return match (true) { + $types->contains(fn (mixed $type): bool => in_array($type, ['video', 'gifv'], true)) => PublicationContentType::Video, + count($attachments) > 1 => PublicationContentType::Carousel, + $types->contains('image') => PublicationContentType::Image, + data_get($row, 'poll') !== null => PublicationContentType::Poll, + filled(data_get($row, 'card.url')) => PublicationContentType::Link, + default => PublicationContentType::Text, + }; + } + + /** @param array $rows */ + private function nextCursor(Response $response, array $rows): ?string + { + $link = $response->header('Link'); + + if (is_string($link) && preg_match('/<([^>]+)>;\s*rel="next"/', $link, $matches) === 1) { + parse_str((string) parse_url(data_get($matches, 1), PHP_URL_QUERY), $query); + $cursor = data_get($query, 'max_id'); + + if (is_scalar($cursor) && (string) $cursor !== '') { + return (string) $cursor; + } + } + + if (count($rows) < self::PAGE_SIZE) { + return null; + } + + $lastId = data_get($rows, (count($rows) - 1).'.id'); + + return is_scalar($lastId) && (string) $lastId !== '' ? (string) $lastId : null; + } + + private function plainText(string $html): string + { + return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5)); + } +} diff --git a/app/Services/Analytics/Collectors/Publications/PinterestPublicationCollector.php b/app/Services/Analytics/Collectors/Publications/PinterestPublicationCollector.php new file mode 100644 index 000000000..f146bfe1e --- /dev/null +++ b/app/Services/Analytics/Collectors/Publications/PinterestPublicationCollector.php @@ -0,0 +1,78 @@ +get($account, config('trypost.platforms.pinterest.api').'/pins', [ + 'page_size' => self::PAGE_SIZE, + 'bookmark' => $cursor, + ]); + $publications = []; + $providerLimited = false; + + foreach ((array) $response->json('items', []) as $row) { + $publishedAt = $this->publishedAt(data_get($row, 'created_at')); + + if (! $publishedAt) { + $providerLimited = true; + + continue; + } + + if ($publishedAt->lessThan($cutoff)) { + continue; + } + + $postId = (string) data_get($row, 'id'); + $providerType = strtolower((string) data_get($row, 'media.media_type')); + $thumbnail = data_get($row, 'media.images.600x.url') + ?: data_get($row, 'media.images.originals.url'); + + $publications[] = new DiscoveredPublication( + providerPostId: $postId, + publishedAt: $publishedAt, + contentType: $this->contentType($providerType), + providerContentType: $providerType ?: null, + permalink: "https://www.pinterest.com/pin/{$postId}/", + excerpt: data_get($row, 'description') ?: data_get($row, 'title'), + previewMetadata: filled($thumbnail) ? ['thumbnail_url' => $thumbnail] : null, + providerMetadata: ['metric_time_basis' => MetricTimeBasis::Lifetime->value], + ); + } + + $nextCursor = data_get($response->json(), 'bookmark'); + $hasNext = is_string($nextCursor) && $nextCursor !== ''; + + return new PublicationPage( + $publications, + $hasNext ? $nextCursor : null, + ! $hasNext, + $providerLimited, + canStopAtTarget: false, + ); + } + + private function contentType(string $providerType): PublicationContentType + { + return match (true) { + str_contains($providerType, 'video') => PublicationContentType::Video, + str_contains($providerType, 'multiple'), str_contains($providerType, 'carousel') => PublicationContentType::Carousel, + str_contains($providerType, 'image') => PublicationContentType::Image, + default => PublicationContentType::Unknown, + }; + } +} diff --git a/app/Services/Analytics/Collectors/Publications/PublicationHistoryCollectorFactory.php b/app/Services/Analytics/Collectors/Publications/PublicationHistoryCollectorFactory.php new file mode 100644 index 000000000..ef6c597c5 --- /dev/null +++ b/app/Services/Analytics/Collectors/Publications/PublicationHistoryCollectorFactory.php @@ -0,0 +1,33 @@ +isIncludedInAnalytics(); + } + + public function for(SocialAccount $account): AbstractPublicationHistoryCollector + { + return match ($account->platform) { + Platform::Instagram, Platform::InstagramFacebook => app(InstagramPublicationCollector::class), + Platform::Facebook => app(FacebookPublicationCollector::class), + Platform::Threads => app(ThreadsPublicationCollector::class), + Platform::X => app(XPublicationCollector::class), + Platform::Pinterest => app(PinterestPublicationCollector::class), + Platform::YouTube => app(YouTubePublicationCollector::class), + Platform::TikTok => app(TikTokPublicationCollector::class), + Platform::Bluesky => app(BlueskyPublicationCollector::class), + Platform::Mastodon => app(MastodonPublicationCollector::class), + default => throw AnalyticsCollectionException::unsupported("{$account->platform->value} publication history is not supported"), + }; + } +} diff --git a/app/Services/Analytics/Collectors/Publications/ThreadsPublicationCollector.php b/app/Services/Analytics/Collectors/Publications/ThreadsPublicationCollector.php new file mode 100644 index 000000000..1b64efcf9 --- /dev/null +++ b/app/Services/Analytics/Collectors/Publications/ThreadsPublicationCollector.php @@ -0,0 +1,73 @@ +get( + $account, + config('trypost.platforms.threads.graph_api')."/{$account->platform_user_id}/threads", + ['fields' => self::FIELDS, 'limit' => self::PAGE_SIZE, 'after' => $cursor], + ); + $publications = []; + $crossedCutoff = false; + $providerLimited = false; + + foreach ((array) $response->json('data', []) as $row) { + $publishedAt = $this->publishedAt(data_get($row, 'timestamp')); + + if (! $publishedAt) { + $providerLimited = true; + + continue; + } + + if ($publishedAt->lessThan($cutoff)) { + $crossedCutoff = true; + + break; + } + + $publications[] = new DiscoveredPublication( + providerPostId: (string) data_get($row, 'id'), + publishedAt: $publishedAt, + contentType: $this->contentType($row), + providerContentType: data_get($row, 'media_product_type') ?: data_get($row, 'media_type'), + permalink: data_get($row, 'permalink'), + excerpt: data_get($row, 'text'), + previewMetadata: $this->preview(data_get($row, 'thumbnail_url') ?: data_get($row, 'media_url')), + providerMetadata: ['is_quote_post' => (bool) data_get($row, 'is_quote_post', false)], + ); + } + + return $this->result($publications, $response, $crossedCutoff, $providerLimited); + } + + /** @param array $row */ + private function contentType(array $row): PublicationContentType + { + return match (strtoupper((string) data_get($row, 'media_type'))) { + 'VIDEO' => PublicationContentType::Video, + 'IMAGE' => PublicationContentType::Image, + 'CAROUSEL_ALBUM' => PublicationContentType::Carousel, + default => PublicationContentType::Text, + }; + } +} diff --git a/app/Services/Analytics/Collectors/Publications/TikTokPublicationCollector.php b/app/Services/Analytics/Collectors/Publications/TikTokPublicationCollector.php new file mode 100644 index 000000000..e5f73faab --- /dev/null +++ b/app/Services/Analytics/Collectors/Publications/TikTokPublicationCollector.php @@ -0,0 +1,117 @@ +scopes ?? [], true)) { + return new PublicationPage([], null, true, true); + } + + $payload = ['max_count' => self::PAGE_SIZE]; + + if (is_string($cursor) && ctype_digit($cursor)) { + $payload['cursor'] = (int) $cursor; + } + + $response = $this->post( + $account, + config('trypost.platforms.tiktok.api').'/video/list/?fields='.self::FIELDS, + $payload, + hasCursor: $cursor !== null, + ); + $errorCode = data_get($response->json(), 'error.code'); + + if (is_string($errorCode) && ! in_array($errorCode, ['', 'ok'], true)) { + if ($cursor !== null && $errorCode === 'invalid_params' && InvalidPublicationCursor::matches($response)) { + throw new AnalyticsCollectionException('invalid_cursor', 'TikTok publication history cursor expired'); + } + + $category = match ($errorCode) { + 'rate_limit_exceeded' => 'rate_limited', + 'internal_error' => 'transient', + default => 'permission', + }; + + throw new AnalyticsCollectionException( + $category, + "TikTok video list failed with {$errorCode}", + ); + } + + $publications = []; + $crossedCutoff = false; + $providerLimited = false; + + foreach ((array) $response->json('data.videos', []) as $row) { + $publishedAt = $this->publishedAt(data_get($row, 'create_time'), timestamp: true); + + if (! $publishedAt) { + $providerLimited = true; + + continue; + } + + if ($publishedAt->lessThan($cutoff)) { + $crossedCutoff = true; + + break; + } + + $postId = (string) data_get($row, 'id'); + $cover = data_get($row, 'cover_image_url'); + + $publications[] = new DiscoveredPublication( + providerPostId: $postId, + publishedAt: $publishedAt, + contentType: PublicationContentType::Video, + providerContentType: 'video', + permalink: data_get($row, 'share_url'), + excerpt: data_get($row, 'video_description') ?: data_get($row, 'title'), + previewMetadata: is_string($cover) && $cover !== '' ? [ + 'thumbnail_url' => $cover, + 'expires_at' => $this->coverExpiresAt($cover), + ] : null, + providerMetadata: ['duration' => data_get($row, 'duration')], + ); + } + + $nextCursor = data_get($response->json(), 'data.cursor'); + $hasNext = (bool) data_get($response->json(), 'data.has_more') + && is_numeric($nextCursor) + && ! $crossedCutoff; + + return new PublicationPage( + $publications, + $hasNext ? (string) $nextCursor : null, + ! $hasNext, + $providerLimited, + ); + } + + private function coverExpiresAt(string $url): ?string + { + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + $expires = data_get($query, 'x-expires') ?? data_get($query, 'expires'); + + return is_scalar($expires) && ctype_digit((string) $expires) + ? CarbonImmutable::createFromTimestampUTC((int) $expires)->toIso8601String() + : null; + } +} diff --git a/app/Services/Analytics/Collectors/Publications/XPublicationCollector.php b/app/Services/Analytics/Collectors/Publications/XPublicationCollector.php new file mode 100644 index 000000000..02adbb53a --- /dev/null +++ b/app/Services/Analytics/Collectors/Publications/XPublicationCollector.php @@ -0,0 +1,92 @@ +get( + $account, + config('trypost.platforms.x.api')."/users/{$account->platform_user_id}/tweets", + [ + 'max_results' => self::PAGE_SIZE, + 'pagination_token' => $cursor, + 'tweet.fields' => 'created_at,attachments', + 'expansions' => 'attachments.media_keys', + 'media.fields' => 'media_key,type,preview_image_url,url', + ], + ); + $media = collect((array) $response->json('includes.media', []))->keyBy('media_key'); + $publications = []; + $crossedCutoff = false; + $providerLimited = false; + + foreach ((array) $response->json('data', []) as $row) { + $publishedAt = $this->publishedAt(data_get($row, 'created_at')); + + if (! $publishedAt) { + $providerLimited = true; + + continue; + } + + if ($publishedAt->lessThan($cutoff)) { + $crossedCutoff = true; + + break; + } + + $attachedMedia = collect((array) data_get($row, 'attachments.media_keys', [])) + ->map(fn (mixed $key) => $media->get((string) $key)) + ->filter(fn (mixed $item): bool => is_array($item)) + ->values(); + $types = $attachedMedia->pluck('type')->filter()->unique()->values(); + $postId = (string) data_get($row, 'id'); + $preview = $attachedMedia->first( + fn (array $item): bool => filled(data_get($item, 'preview_image_url')) || filled(data_get($item, 'url')), + ); + + $publications[] = new DiscoveredPublication( + providerPostId: $postId, + publishedAt: $publishedAt, + contentType: $this->contentType($types->all(), $attachedMedia->count()), + providerContentType: $types->implode(','), + permalink: filled($account->username) ? "https://x.com/{$account->username}/status/{$postId}" : null, + excerpt: data_get($row, 'text'), + previewMetadata: is_array($preview) + ? ['thumbnail_url' => data_get($preview, 'preview_image_url') ?: data_get($preview, 'url')] + : null, + ); + } + + $nextCursor = data_get($response->json(), 'meta.next_token'); + $hasNext = is_string($nextCursor) && $nextCursor !== '' && ! $crossedCutoff; + + return new PublicationPage($publications, $hasNext ? $nextCursor : null, ! $hasNext, $providerLimited); + } + + /** @param list $types */ + private function contentType(array $types, int $mediaCount): PublicationContentType + { + if (in_array('video', $types, true) || in_array('animated_gif', $types, true)) { + return PublicationContentType::Video; + } + + if ($mediaCount > 1) { + return PublicationContentType::Carousel; + } + + return in_array('photo', $types, true) ? PublicationContentType::Image : PublicationContentType::Text; + } +} diff --git a/app/Services/Analytics/Collectors/Publications/YouTubePublicationCollector.php b/app/Services/Analytics/Collectors/Publications/YouTubePublicationCollector.php new file mode 100644 index 000000000..514053e4b --- /dev/null +++ b/app/Services/Analytics/Collectors/Publications/YouTubePublicationCollector.php @@ -0,0 +1,105 @@ +get($account, "{$api}/channels", [ + 'part' => 'contentDetails', + 'id' => $account->platform_user_id, + 'maxResults' => 1, + ]); + $uploadsPlaylist = data_get($channel->json(), 'items.0.contentDetails.relatedPlaylists.uploads'); + + if (! is_string($uploadsPlaylist) || $uploadsPlaylist === '') { + throw AnalyticsCollectionException::malformed('YouTube channel did not expose an uploads playlist'); + } + + $playlist = $this->get($account, "{$api}/playlistItems", [ + 'part' => 'contentDetails', + 'playlistId' => $uploadsPlaylist, + 'maxResults' => self::PAGE_SIZE, + 'pageToken' => $cursor, + ]); + $videoIds = collect((array) $playlist->json('items', [])) + ->pluck('contentDetails.videoId') + ->filter(fn (mixed $id): bool => is_string($id) && $id !== '') + ->values(); + $videos = collect(); + + if ($videoIds->isNotEmpty()) { + $videosResponse = $this->get($account, "{$api}/videos", [ + 'part' => 'snippet,contentDetails', + 'id' => $videoIds->implode(','), + 'maxResults' => self::PAGE_SIZE, + ]); + $videos = collect((array) $videosResponse->json('items', []))->keyBy('id'); + } + + $publications = []; + $crossedCutoff = false; + $providerLimited = false; + + foreach ($videoIds as $videoId) { + $row = $videos->get($videoId); + + if (! is_array($row)) { + $providerLimited = true; + + continue; + } + + $publishedAt = $this->publishedAt(data_get($row, 'snippet.publishedAt')); + + if (! $publishedAt) { + $providerLimited = true; + + continue; + } + + if ($publishedAt->lessThan($cutoff)) { + $crossedCutoff = true; + + break; + } + + $thumbnail = data_get($row, 'snippet.thumbnails.maxres.url') + ?: data_get($row, 'snippet.thumbnails.high.url') + ?: data_get($row, 'snippet.thumbnails.medium.url') + ?: data_get($row, 'snippet.thumbnails.default.url'); + + $publications[] = new DiscoveredPublication( + providerPostId: (string) $videoId, + publishedAt: $publishedAt, + contentType: PublicationContentType::Video, + providerContentType: 'video', + permalink: "https://www.youtube.com/watch?v={$videoId}", + excerpt: data_get($row, 'snippet.description') ?: data_get($row, 'snippet.title'), + previewMetadata: filled($thumbnail) ? ['thumbnail_url' => $thumbnail] : null, + providerMetadata: [ + 'duration' => data_get($row, 'contentDetails.duration'), + 'short_classification' => 'unknown', + ], + ); + } + + $nextCursor = data_get($playlist->json(), 'nextPageToken'); + $hasNext = is_string($nextCursor) && $nextCursor !== '' && ! $crossedCutoff; + + return new PublicationPage($publications, $hasNext ? $nextCursor : null, ! $hasNext, $providerLimited); + } +} diff --git a/app/Services/Image/TemplateImageGenerator.php b/app/Services/Image/TemplateImageGenerator.php index b0294f84f..203947a83 100644 --- a/app/Services/Image/TemplateImageGenerator.php +++ b/app/Services/Image/TemplateImageGenerator.php @@ -4,11 +4,11 @@ namespace App\Services\Image; +use App\Actions\Ai\RecordAiUsage; use App\Enums\Workspace\ImageStyle; use App\Models\SocialAccount; use App\Models\Workspace; use App\Services\Ai\AiImageClient; -use App\Services\Ai\RecordAiUsage; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Intervention\Image\Drivers\Gd\Driver; diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php deleted file mode 100644 index dec6438a9..000000000 --- a/app/Services/Post/PostMetricsFetcher.php +++ /dev/null @@ -1,88 +0,0 @@ -|array{unsupported: true, reason: string} - * }> - */ - public function forPost(Post $post): Collection - { - return $post->postPlatforms - ->where('enabled', true) - ->values() - ->map(function (PostPlatform $pp): array { - $metrics = $this->forPlatform($pp); - - return [ - 'post_platform_id' => $pp->id, - 'platform' => $pp->platform->value, - 'status' => $pp->status->value, - 'platform_post_id' => $pp->platform_post_id, - 'platform_url' => $pp->platform_url, - 'metrics' => $metrics, - ]; - }); - } - - /** - * @return array|array{unsupported: true, reason: string} - */ - public function forPlatform(PostPlatform $postPlatform): array - { - if ($postPlatform->status->value !== 'published' || ! $postPlatform->platform_post_id) { - return ['unsupported' => true, 'reason' => 'not_published']; - } - - return Cache::remember("post_metrics:{$postPlatform->id}", 300, fn () => match ($postPlatform->platform) { - Platform::X => app(XAnalytics::class)->fetchPostMetrics($postPlatform), - Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform), - Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform), - Platform::Telegram => app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform), - Platform::Discord => app(DiscordAnalytics::class)->fetchPostMetrics($postPlatform), - Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->fetchPostMetrics($postPlatform), - Platform::Facebook => app(FacebookAnalytics::class)->fetchPostMetrics($postPlatform), - Platform::Threads => app(ThreadsAnalytics::class)->fetchPostMetrics($postPlatform), - Platform::LinkedIn => app(LinkedInAnalytics::class)->fetchPostMetrics($postPlatform), - Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->fetchPostMetrics($postPlatform), - Platform::YouTube => app(YouTubeAnalytics::class)->fetchPostMetrics($postPlatform), - Platform::Pinterest => app(PinterestAnalytics::class)->fetchPostMetrics($postPlatform), - Platform::TikTok => app(TikTokAnalytics::class)->fetchPostMetrics($postPlatform), - default => ['unsupported' => true, 'reason' => 'platform_not_supported'], - }); - } -} diff --git a/app/Services/Repurpose/CaptionAdapter.php b/app/Services/Repurpose/CaptionAdapter.php index a3e1ca575..d4ff19a89 100644 --- a/app/Services/Repurpose/CaptionAdapter.php +++ b/app/Services/Repurpose/CaptionAdapter.php @@ -4,11 +4,11 @@ namespace App\Services\Repurpose; +use App\Actions\Ai\RecordAiUsage; use App\Ai\Agents\PostContentShortener; use App\Enums\SocialAccount\Platform; use App\Models\User; use App\Models\Workspace; -use App\Services\Ai\RecordAiUsage; use App\Services\Social\ContentSanitizer; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Str; diff --git a/app/Services/Repurpose/RepurposeAccountSync.php b/app/Services/Repurpose/RepurposeAccountSync.php index d0d871dde..d3c45bbc1 100644 --- a/app/Services/Repurpose/RepurposeAccountSync.php +++ b/app/Services/Repurpose/RepurposeAccountSync.php @@ -9,7 +9,6 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\Repurpose\PauseReason; use App\Enums\Repurpose\Status; -use App\Enums\SocialAccount\Status as AccountStatus; use App\Models\Repurpose; use App\Models\SocialAccount; use App\Support\Repurpose\RepurposeTransition; @@ -62,8 +61,8 @@ private function isUsable(SocialAccount $account): bool { return SocialAccount::query() ->whereKey($account->id) - ->where('is_active', true) - ->where('status', AccountStatus::Connected) + ->connected() + ->active() ->exists(); } diff --git a/app/Services/Social/BlueskyLexicon.php b/app/Services/Social/BlueskyLexicon.php index ae56ad848..99ef78c71 100644 --- a/app/Services/Social/BlueskyLexicon.php +++ b/app/Services/Social/BlueskyLexicon.php @@ -15,6 +15,8 @@ final class BlueskyLexicon public const CREATE_RECORD = 'com.atproto.repo.createRecord'; + public const LIST_RECORDS = 'com.atproto.repo.listRecords'; + public const UPLOAD_BLOB = 'com.atproto.repo.uploadBlob'; public const CREATE_SESSION = 'com.atproto.server.createSession'; diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index bca4cb6fb..8d6b0a5ef 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -27,13 +27,6 @@ class TikTokAnalytics private const int VIDEO_LIST_MAX_PAGES = 5; - /** - * `published_at` is stamped after TikTok finishes processing, which can trail - * the video's `create_time` by up to the status-poll window (~1 h). A day of - * slack keeps our own video inside the scan on slow publishes. - */ - private const int PUBLISH_CLOCK_SLACK_SECONDS = 86400; - /** * @var array */ @@ -184,7 +177,8 @@ private function publicVideoIdFromStatus(string $publishId): ?string /** * `video/list` is sorted by `create_time` desc, so scanning stops at the * first video older than the publish — anything past it cannot be ours, and - * an older repost with the same caption must never be claimed. + * an older repost with the same caption must never be claimed. Allow one + * day because our `published_at` can lag TikTok's `create_time` during review. */ private function matchVideoFromRecentList(PostPlatform $postPlatform): ?string { @@ -200,7 +194,7 @@ private function matchVideoFromRecentList(PostPlatform $postPlatform): ?string return null; } - $notBefore = ($postPlatform->published_at ?? now())->getTimestamp() - self::PUBLISH_CLOCK_SLACK_SECONDS; + $notBefore = ($postPlatform->published_at ?? now())->copy()->utc()->subDay()->getTimestamp(); $cursor = null; for ($page = 0; $page < self::VIDEO_LIST_MAX_PAGES; $page++) { diff --git a/app/Support/Analytics/AnalyticsJobLog.php b/app/Support/Analytics/AnalyticsJobLog.php new file mode 100644 index 000000000..8045184a2 --- /dev/null +++ b/app/Support/Analytics/AnalyticsJobLog.php @@ -0,0 +1,39 @@ + $account->workspace_id, + 'social_account_key' => $this->accountKeys->for($account), + 'platform' => $account->platform->value, + 'collector' => $collector, + 'date_or_cursor' => $dateOrCursor, + 'attempt' => $attempt, + 'category' => $category, + ]; + + if ($retryAt !== null) { + $context['retry_at'] = $retryAt; + } + + Log::info('analytics.collection', $context); + } +} diff --git a/app/Support/Analytics/InvalidPublicationCursor.php b/app/Support/Analytics/InvalidPublicationCursor.php new file mode 100644 index 000000000..1cd9d2b07 --- /dev/null +++ b/app/Support/Analytics/InvalidPublicationCursor.php @@ -0,0 +1,35 @@ +status() !== 400 && ! $response->successful()) { + return false; + } + + $reason = data_get($response->json(), 'error.errors.0.reason'); + + if ($reason === 'invalidPageToken') { + return true; + } + + foreach (['error.message', 'message', 'detail', 'title', 'error_description'] as $path) { + $message = data_get($response->json(), $path); + + if (is_string($message) + && preg_match('/\b(invalid|expired|malformed)\b/i', $message) + && preg_match('/\b(cursor|page[\s_-]?token|pagination[\s_-]?token|bookmark)\b/i', $message)) { + return true; + } + } + + return false; + } +} diff --git a/app/Support/Analytics/MetaAnalyticsResponse.php b/app/Support/Analytics/MetaAnalyticsResponse.php new file mode 100644 index 000000000..0512d59d4 --- /dev/null +++ b/app/Support/Analytics/MetaAnalyticsResponse.php @@ -0,0 +1,33 @@ +successful()) { + return $response; + } + + $code = (int) data_get($response->json(), 'error.code', 0); + $category = match (true) { + $response->status() === 429, in_array($code, [4, 17, 32, 80001, 80002], true) => 'rate_limited', + $response->status() === 401, $code === 190 => 'authentication', + $response->status() === 403, in_array($code, [10, 200], true) => 'permission', + $response->serverError(), in_array($code, [1, 2], true) => 'transient', + default => 'malformed', + }; + + throw new AnalyticsCollectionException( + $category, + "{$context} failed with HTTP {$response->status()}", + RetryAfter::from($response), + ); + } +} diff --git a/app/Support/Analytics/MetricComparison.php b/app/Support/Analytics/MetricComparison.php new file mode 100644 index 000000000..2162d254b --- /dev/null +++ b/app/Support/Analytics/MetricComparison.php @@ -0,0 +1,19 @@ + $current, + 'previous' => $previous, + 'change' => $current !== null && $previous !== null && $previous != 0 + ? round(($current - $previous) / $previous * 100, 2) : null, + ]; + } +} diff --git a/app/Support/Analytics/PeriodBuckets.php b/app/Support/Analytics/PeriodBuckets.php new file mode 100644 index 000000000..020f5fe0a --- /dev/null +++ b/app/Support/Analytics/PeriodBuckets.php @@ -0,0 +1,43 @@ +days() <= 14 => 'daily', + $range->days() <= 90 => 'weekly', + default => 'monthly', + }; + } + + /** @return list */ + public function for(DateRange $range): array + { + $resolution = $this->resolution($range); + $cursor = $range->start; + $buckets = []; + + while ($cursor->lessThanOrEqualTo($range->end)) { + $boundary = match ($resolution) { + 'weekly' => $cursor->endOfWeek(), + 'monthly' => $cursor->endOfMonth(), + default => $cursor, + }; + $last = $boundary->lessThan($range->end) ? $boundary : $range->end; + $buckets[] = [ + 'start' => $cursor->toDateString(), + 'end' => $last->toDateString(), + ]; + $cursor = $last->addDay()->startOfDay(); + } + + return $buckets; + } +} diff --git a/app/Support/Analytics/RetryAfter.php b/app/Support/Analytics/RetryAfter.php new file mode 100644 index 000000000..0e8444054 --- /dev/null +++ b/app/Support/Analytics/RetryAfter.php @@ -0,0 +1,33 @@ +header('Retry-After'); + + if (! is_string($header) || trim($header) === '') { + return null; + } + + $header = trim($header); + + if (ctype_digit($header)) { + return CarbonImmutable::now('UTC')->addSeconds((int) $header); + } + + try { + return CarbonImmutable::parse($header)->utc(); + } catch (Throwable) { + return null; + } + } +} diff --git a/boost.json b/boost.json index 8dac4b120..80f344e44 100644 --- a/boost.json +++ b/boost.json @@ -2,7 +2,8 @@ "agents": [ "claude_code", "cursor", - "codex" + "codex", + "antigravity" ], "cloud": false, "guidelines": true, diff --git a/composer.json b/composer.json index e86d17de6..5e01b2cc1 100644 --- a/composer.json +++ b/composer.json @@ -102,7 +102,7 @@ ], "dev": [ "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" + "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --queue=default,analytics --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" ], "lint": [ "pint --parallel" diff --git a/config/horizon.php b/config/horizon.php index 954deb54d..98296624e 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -270,6 +270,21 @@ 'tries' => 1, 'nice' => 0, ], + + 'analytics' => [ + 'connection' => 'redis', + 'queue' => ['analytics'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'minProcesses' => 1, + 'maxProcesses' => 2, + 'timeout' => 630, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 256, + 'tries' => 1, + 'nice' => 0, + ], ], 'environments' => [ @@ -297,6 +312,12 @@ 'balanceMaxShift' => 1, 'balanceCooldown' => 3, ], + + 'analytics' => [ + 'maxProcesses' => 4, + 'balanceMaxShift' => 1, + 'balanceCooldown' => 3, + ], ], 'local' => [ @@ -315,6 +336,10 @@ 'webhooks' => [ 'maxProcesses' => 1, ], + + 'analytics' => [ + 'maxProcesses' => 1, + ], ], ], ]; diff --git a/database/factories/AnalyticsAccountDailySnapshotFactory.php b/database/factories/AnalyticsAccountDailySnapshotFactory.php new file mode 100644 index 000000000..e1e96b972 --- /dev/null +++ b/database/factories/AnalyticsAccountDailySnapshotFactory.php @@ -0,0 +1,45 @@ + + */ +class AnalyticsAccountDailySnapshotFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'social_account_id' => null, + 'social_account_key' => fake()->uuid(), + 'network' => Platform::Instagram->network(), + 'platform_user_id' => fake()->uuid(), + 'platform' => Platform::Instagram, + 'account_display_name' => fake()->name(), + 'account_username' => fake()->userName(), + 'account_avatar_url' => fake()->imageUrl(), + 'date' => today(), + 'followers_count' => fake()->numberBetween(0, 100000), + 'metrics' => null, + 'provenance' => ObservationProvenance::Actual, + 'precision' => MetricPrecision::Exact, + 'provider_observed_at' => now(), + 'collected_at' => now(), + ]; + } +} diff --git a/database/factories/AnalyticsPublicationDailySnapshotFactory.php b/database/factories/AnalyticsPublicationDailySnapshotFactory.php new file mode 100644 index 000000000..fa1747dfd --- /dev/null +++ b/database/factories/AnalyticsPublicationDailySnapshotFactory.php @@ -0,0 +1,31 @@ + + */ +class AnalyticsPublicationDailySnapshotFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'publication_id' => AnalyticsPublication::factory(), + 'date' => today(), + 'collected_at' => now(), + 'provider_observed_at' => now(), + 'metrics' => null, + ]; + } +} diff --git a/database/factories/AnalyticsPublicationFactory.php b/database/factories/AnalyticsPublicationFactory.php new file mode 100644 index 000000000..afd31c296 --- /dev/null +++ b/database/factories/AnalyticsPublicationFactory.php @@ -0,0 +1,53 @@ + + */ +class AnalyticsPublicationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'social_account_id' => null, + 'social_account_key' => fake()->uuid(), + 'post_platform_id' => null, + 'network' => Platform::Instagram->network(), + 'platform_user_id' => fake()->uuid(), + 'platform' => Platform::Instagram, + 'remote_id' => fake()->uuid(), + 'provider_published_at' => now()->subDay(), + 'origin' => PublicationOrigin::External, + 'content_type' => PublicationContentType::Image, + 'availability' => PublicationAvailability::Available, + 'provider_content_type' => 'IMAGE', + 'permalink' => fake()->url(), + 'excerpt' => fake()->sentence(), + 'preview_metadata' => null, + 'account_display_name' => fake()->name(), + 'account_username' => fake()->userName(), + 'account_avatar_url' => fake()->imageUrl(), + 'first_seen_at' => now(), + 'last_seen_at' => now(), + 'provider_synced_at' => now(), + 'provider_metadata' => null, + ]; + } +} diff --git a/database/factories/AnalyticsSyncStateFactory.php b/database/factories/AnalyticsSyncStateFactory.php new file mode 100644 index 000000000..bca002929 --- /dev/null +++ b/database/factories/AnalyticsSyncStateFactory.php @@ -0,0 +1,38 @@ + + */ +class AnalyticsSyncStateFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'social_account_id' => SocialAccount::factory(), + 'identity_key' => hash('sha256', fake()->uuid()), + 'collector' => SyncCollector::PublicationBackfill, + 'status' => SyncStatus::Pending, + 'checkpoint' => null, + 'target_since' => now()->subYear(), + 'oldest_reached_at' => null, + 'high_watermark_at' => null, + 'last_success_at' => null, + 'last_error_category' => null, + ]; + } +} diff --git a/database/migrations/2026_09_23_142033_create_analytics_account_daily_snapshots_table.php b/database/migrations/2026_09_23_142033_create_analytics_account_daily_snapshots_table.php new file mode 100644 index 000000000..7697d7e47 --- /dev/null +++ b/database/migrations/2026_09_23_142033_create_analytics_account_daily_snapshots_table.php @@ -0,0 +1,48 @@ +uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('social_account_id')->nullable()->constrained()->nullOnDelete(); + $table->uuid('social_account_key'); + $table->string('network', 32); + $table->string('platform_user_id', 191); + $table->string('platform', 32); + $table->string('account_display_name')->nullable(); + $table->string('account_username')->nullable(); + $table->text('account_avatar_url')->nullable(); + $table->date('date'); + $table->bigInteger('followers_count')->nullable(); + $table->json('metrics')->nullable(); + $table->string('provenance', 32); + $table->string('precision', 32); + $table->timestamp('provider_observed_at')->nullable(); + $table->timestamp('collected_at'); + $table->timestamps(); + + $table->unique(['social_account_key', 'date']); + $table->index(['workspace_id', 'date']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_account_daily_snapshots'); + } +}; diff --git a/database/migrations/2026_09_23_142034_create_analytics_publications_table.php b/database/migrations/2026_09_23_142034_create_analytics_publications_table.php new file mode 100644 index 000000000..1f8944225 --- /dev/null +++ b/database/migrations/2026_09_23_142034_create_analytics_publications_table.php @@ -0,0 +1,57 @@ +uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('social_account_id')->nullable()->constrained()->nullOnDelete(); + $table->uuid('social_account_key'); + $table->foreignUuid('post_platform_id')->nullable()->constrained()->nullOnDelete(); + $table->string('network', 32); + $table->string('platform_user_id', 191); + $table->string('platform', 32); + $table->string('remote_id', 191); + $table->timestamp('provider_published_at'); + $table->string('origin', 32); + $table->string('content_type', 32); + $table->string('availability', 32); + $table->string('provider_content_type', 64)->nullable(); + $table->text('permalink')->nullable(); + $table->text('excerpt')->nullable(); + $table->json('preview_metadata')->nullable(); + $table->string('account_display_name')->nullable(); + $table->string('account_username')->nullable(); + $table->text('account_avatar_url')->nullable(); + $table->timestamp('first_seen_at'); + $table->timestamp('last_seen_at'); + $table->timestamp('provider_synced_at')->nullable(); + $table->json('provider_metadata')->nullable(); + $table->timestamps(); + + $table->unique('post_platform_id'); + $table->unique(['social_account_key', 'remote_id']); + $table->index(['workspace_id', 'provider_published_at']); + $table->index(['workspace_id', 'platform_user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_publications'); + } +}; diff --git a/database/migrations/2026_09_23_142035_create_analytics_publication_daily_snapshots_table.php b/database/migrations/2026_09_23_142035_create_analytics_publication_daily_snapshots_table.php new file mode 100644 index 000000000..b200cf251 --- /dev/null +++ b/database/migrations/2026_09_23_142035_create_analytics_publication_daily_snapshots_table.php @@ -0,0 +1,49 @@ +uuid('id')->primary(); + $table->uuid('publication_id'); + $table->date('date'); + $table->timestamp('collected_at'); + $table->timestamp('provider_observed_at')->nullable(); + $table->json('metrics')->nullable(); + $table->bigInteger('reactions_count')->nullable(); + $table->bigInteger('comments_count')->nullable(); + $table->bigInteger('shares_count')->nullable(); + $table->bigInteger('saves_count')->nullable(); + $table->bigInteger('views_count')->nullable(); + $table->bigInteger('impressions_count')->nullable(); + $table->bigInteger('reach_count')->nullable(); + $table->bigInteger('engagement_count')->nullable(); + $table->bigInteger('exposure_count')->nullable(); + $table->string('exposure_kind', 32)->nullable(); + $table->bigInteger('watch_time_milliseconds')->nullable(); + $table->bigInteger('average_watch_time_milliseconds')->nullable(); + $table->timestamps(); + + $table->foreign('publication_id')->references('id')->on('analytics_publications')->cascadeOnDelete(); + $table->unique(['publication_id', 'date']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_publication_daily_snapshots'); + } +}; diff --git a/database/migrations/2026_09_23_142036_create_analytics_sync_states_table.php b/database/migrations/2026_09_23_142036_create_analytics_sync_states_table.php new file mode 100644 index 000000000..e17497b27 --- /dev/null +++ b/database/migrations/2026_09_23_142036_create_analytics_sync_states_table.php @@ -0,0 +1,46 @@ +uuid('id')->primary(); + $table->foreignUuid('social_account_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignUuid('workspace_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('network', 32)->nullable(); + $table->string('platform_user_id', 191)->nullable(); + $table->string('identity_key', 64); + $table->string('collector', 32); + $table->string('status', 32); + $table->json('checkpoint')->nullable(); + $table->timestamp('target_since')->nullable(); + $table->timestamp('oldest_reached_at')->nullable(); + $table->timestamp('high_watermark_at')->nullable(); + $table->timestamp('last_success_at')->nullable(); + $table->string('last_error_category', 64)->nullable(); + $table->timestamps(); + + $table->unique(['social_account_id', 'collector']); + $table->unique(['identity_key', 'collector']); + $table->index(['collector', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_sync_states'); + } +}; diff --git a/docs/superpowers/plans/2026-09-23-workspace-analytics-backfill.md b/docs/superpowers/plans/2026-09-23-workspace-analytics-backfill.md new file mode 100644 index 000000000..e07e06020 --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-workspace-analytics-backfill.md @@ -0,0 +1,1395 @@ +# Workspace Analytics and Native Backfill Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace request-time social analytics with workspace-scoped, database-backed follower history, reconciled TryPost/external publication history, persisted post metrics, and the Summary, Followers, Posts, Top 5 Posts, Performance, and individual-publication views. + +**Architecture:** Four tables separate daily account facts, publication identity, daily cumulative publication metrics, and a deliberately small operational checkpoint used only by publication backfill/discovery. Every provider call runs in an isolated queued job; page, API, and MCP reads use local database-backed Actions only. Provider adapters normalize platform responses into stable DTOs, while a hybrid scalar-plus-JSON snapshot keeps cross-network queries portable across PostgreSQL and MySQL and preserves content-specific metrics. + +**Tech Stack:** PHP 8.5, Laravel 13.24, Horizon 5.47, PostgreSQL and MySQL, Inertia Vue 3.6, Vue 3.5, Tailwind CSS 4, Pest 5, Pest Browser 5. + +**Spec:** `docs/superpowers/specs/2026-09-23-workspace-follower-analytics-design.md` + +**Execution status (2026-09-23):** Tasks 1–15 have been implemented and +committed on the requested branch, with their focused tests recorded in the +execution ledger. Task 16's code, PostgreSQL/MySQL tests, browser tests, and a +single-workspace local Threads canary are complete. Its production provider +capability checks, production canary, and global rollout remain open; the +unchecked implementation steps below are the original TDD recipe, not a claim +that their code has not been written. + +**Read-model refactor (2026-09-23):** The two classes originally planned under +`app/Queries/Analytics` were removed to match the project's existing Actions +structure. `BuildWorkspaceAnalyticsReport` orchestrates the publication and +follower report Actions; `GetAnalyticsBounds` serves the date picker; +`ReadPublicationAnalytics` serves individual post metrics. The controller passes +the bounds it already read into the report Action to avoid a duplicate query. + +## Global Constraints + +- Execute all tasks on one branch named `feat/workspace-analytics-backfill`. +- Analytics tenancy and aggregation are always scoped to the current workspace; never trust a request-provided social-account id as tenancy proof. +- Multiple accounts on the same network remain separate series and Performance rows through `social_account_id` while connected and `social_account_key` historically; reconnecting the same workspace + network + provider user id reuses its historical key. +- V1 includes TikTok, Instagram, Instagram through Facebook, Facebook Pages, Threads, X, Pinterest, YouTube, Bluesky, and Mastodon. +- V1 excludes LinkedIn profile, LinkedIn Page, Telegram, Discord, and Google Business Profile from every analytics read, collection job, summary, chart, and post-detail block. +- LinkedIn profile and Page analytics remain a separate V2 after Community Management API approval. +- `origin=trypost` means a matching TryPost destination proves ownership; every other discovered publication is `origin=external` and renders `Published on `. +- Target 365 days of owned-publication history, but persist and expose actual coverage when a provider is shallower, partial, or permission-limited. +- Never fabricate follower history, historical post-metric snapshots, unsupported metrics, zero values after provider failure, or a native/manual origin the provider cannot prove. +- Followers retry at widely spaced same-day windows and carry the last value forward only after the day is exhausted; provider `Retry-After` wins when valid. +- An analytics-endpoint authentication or permission rejection is not proof that a connected account cannot publish; record the analytics failure without changing the account's global status. The independent connection verifier owns that health decision. +- `/analytics`, post detail, REST, and MCP make no social-provider calls and never use Redis as the analytics source of truth. +- Common aggregate metrics are nullable scalar columns; content-specific metrics use stable enum-backed JSON keys with value, unit, time basis, precision, availability, and provider identity. +- `analytics_sync_states` is not a job ledger: only publication backfill/discovery use it. Queue/Horizon owns attempts and delays; follower and publication snapshots prove successful collection. +- Use string columns plus PHP backed enums; do not use database-native enum types. +- Every query, migration, unique constraint, and test must work on PostgreSQL and MySQL. +- Do not add a charting dependency; use focused Vue/SVG/CSS components and existing UI primitives. +- Do not alter the unrelated `package-lock.json` change. Preserve the existing + metric inventory in `ANALYTIC.md`; its only planning change is the note that + distinguishes current behavior from this V1 design. +- Generate Laravel files with `php artisan make:* --no-interaction`, use Pest TDD, run `vendor/bin/pint --dirty --format agent` after PHP edits, and commit after each task. + +## Review Focus + +- Two Instagram accounts in one workspace must remain independent in totals, charts, publication counts, and sorting; Task 12 adds a cross-network-duplicate account test. +- Deleting a live social account must null the foreign key without erasing historical identity or presentation; Tasks 1 and 2 test `social_account_key` and snapshot retention. +- Provider null/missing metrics must stay unavailable while a measured numeric zero remains zero; Tasks 3 and 10 add explicit parser and writer tests. +- Concurrent TryPost sync and external discovery of the same provider post id must converge to one publication with `trypost` origin; Task 5 tests both arrival orders. +- A failed paginated backfill must resume from the last committed cursor and disclose partial/provider-limited coverage instead of restarting or claiming 365 days; Task 9 tests checkpoint, retry, and completion conditions. +- A duplicate or stale page job must never move a provider cursor backward; Task 9 uses a captured checkpoint version and row lock around advancement. +- Pre-rollout `post_platforms` whose social account was already deleted cannot be safely assigned by username; Task 9 skips and reports them instead of inventing historical identity. + +--- + +## File Structure + +The implementation introduces these focused areas: + +- `app/Enums/Analytics/*`: stable persisted states, metric keys, units, and provenance. +- `app/Models/Analytics*`: four persistence boundaries and their relationships. +- `app/Dto/Analytics/*`: provider-independent account, publication, page, and metric results. +- `app/Contracts/Analytics/*`: follower, history, and publication-metric collector contracts. +- `app/Services/Analytics/Collectors/*`: one provider adapter per concern; no authorization or database writes. +- `app/Actions/Analytics/*`: idempotent writers, publication reconciliation, and local analytics read/report Actions. +- `app/Jobs/Analytics/*`: one bounded piece of external or local synchronization per job. +- `app/Console/Commands/Analytics/*`: chunked dispatchers and rollout entry points; commands never call providers. +- `resources/js/components/analytics/workspace/*`: reusable dashboard cards and dependency-free SVG/CSS charts. +- `tests/Feature/Analytics/*`, `tests/Unit/Analytics/*`, and `tests/Browser/WorkspaceAnalyticsTest.php`: provider contracts, persistence, queue behavior, read paths, and UI coverage. + +### Task 1: Create the four-table analytics schema and persisted enums + +**Files:** +- Create: `database/migrations/2026_09_23_103300_create_analytics_account_daily_snapshots_table.php` +- Create: `database/migrations/2026_09_23_103301_create_analytics_publications_table.php` +- Create: `database/migrations/2026_09_23_103302_create_analytics_publication_daily_snapshots_table.php` +- Create: `database/migrations/2026_09_23_103303_create_analytics_sync_states_table.php` +- Create: `app/Enums/Analytics/ObservationProvenance.php` +- Create: `app/Enums/Analytics/MetricPrecision.php` +- Create: `app/Enums/Analytics/PublicationOrigin.php` +- Create: `app/Enums/Analytics/PublicationAvailability.php` +- Create: `app/Enums/Analytics/PublicationContentType.php` +- Create: `app/Enums/Analytics/ExposureKind.php` +- Create: `app/Enums/Analytics/MetricUnit.php` +- Create: `app/Enums/Analytics/MetricTimeBasis.php` +- Create: `app/Enums/Analytics/MetricAvailability.php` +- Create: `app/Enums/Analytics/MetricKey.php` +- Create: `app/Enums/Analytics/SyncCollector.php` +- Create: `app/Enums/Analytics/SyncStatus.php` +- Test: `tests/Feature/Analytics/AnalyticsSchemaTest.php` + +**Interfaces:** +- Consumes: existing UUID workspace, social-account, and post-platform keys; `App\Enums\SocialAccount\Platform`. +- Produces: the four tables and enum values consumed by every later task. + +- [ ] **Step 1: Generate the migrations and failing schema test** + +Run: + +```bash +php artisan make:migration create_analytics_account_daily_snapshots_table --no-interaction +php artisan make:migration create_analytics_publications_table --no-interaction +php artisan make:migration create_analytics_publication_daily_snapshots_table --no-interaction +php artisan make:migration create_analytics_sync_states_table --no-interaction +php artisan make:test --pest Analytics/AnalyticsSchemaTest --no-interaction +``` + +Add assertions that all four tables exist, that account and publication rows accept two distinct account UUIDs on the same platform, and that deleting `social_accounts` nulls live foreign keys while `social_account_key`, platform, username, and historical facts remain. + +```php +expect(Schema::hasColumns('analytics_account_daily_snapshots', [ + 'workspace_id', 'social_account_id', 'social_account_key', 'platform', + 'network', 'platform_user_id', + 'snapshot_date', 'followers_count', 'metrics', 'provenance', 'precision', + 'provider_observed_at', 'collected_at', +]))->toBeTrue(); +``` + +- [ ] **Step 2: Run the schema test and verify it fails** + +Run: `php artisan test --compact tests/Feature/Analytics/AnalyticsSchemaTest.php` + +Expected: FAIL because the migrations and enum classes are empty or incomplete. + +- [ ] **Step 3: Define the exact persisted enum vocabulary** + +Use backed string enums. The cases and values are: + +```php +enum ObservationProvenance: string { case Actual = 'actual'; case CarriedForward = 'carried_forward'; } +enum MetricPrecision: string { case Exact = 'exact'; case Approximate = 'approximate'; case Estimated = 'estimated'; case Experimental = 'experimental'; } +enum PublicationOrigin: string { case TryPost = 'trypost'; case External = 'external'; } +enum PublicationAvailability: string { case Available = 'available'; case Deleted = 'deleted'; case Unavailable = 'unavailable'; } +enum ExposureKind: string { case Reach = 'reach'; case Impressions = 'impressions'; case Views = 'views'; } +enum MetricUnit: string { case Count = 'count'; case Milliseconds = 'milliseconds'; case Percent = 'percent'; } +enum MetricTimeBasis: string { case Lifetime = 'lifetime'; case Range = 'range'; case Rolling90Days = 'rolling_90_days'; case Snapshot = 'snapshot'; } +enum MetricAvailability: string { case Available = 'available'; case Unsupported = 'unsupported'; case Unavailable = 'unavailable'; case Delayed = 'delayed'; case PrivacyLimited = 'privacy_limited'; } +enum SyncCollector: string { case PublicationBackfill = 'publication_backfill'; case PublicationDiscovery = 'publication_discovery'; } +enum SyncStatus: string { case Pending = 'pending'; case Running = 'running'; case Complete = 'complete'; case Partial = 'partial'; case ProviderLimited = 'provider_limited'; case Failed = 'failed'; } +``` + +`PublicationContentType` must contain `Text`, `Image`, `Carousel`, `Video`, `Reel`, `Story`, `Short`, `Link`, `Poll`, and `Unknown`. `MetricKey` must contain every metric in the spec catalog, including normalized reactions/comments/shares/saves/views/impressions/reach, watch-time metrics, clicks, video quartiles, follows, profile activity, Story navigation, Pinterest audience metrics, and YouTube subscriber gains/losses. + +- [ ] **Step 4: Implement portable migrations and indexes** + +Use UUID primary keys, string-backed enum columns, and explicit foreign keys. + +`analytics_account_daily_snapshots` has `workspace_id` with cascade delete; +nullable `social_account_id` with null-on-delete; non-null +`social_account_key`, `network`, `platform_user_id`, and `platform`; account +name/username/avatar snapshots; `snapshot_date`; nullable +`followers_count`; nullable future account `metrics` JSON; `provenance`, +`precision`, nullable `provider_observed_at`, `collected_at`, and timestamps. Its +unique key is `workspace_id, social_account_key, snapshot_date`. + +`analytics_publications` has `workspace_id` with cascade delete; nullable live +`social_account_id` and unique nullable `post_platform_id`, both null-on-delete; +non-null `social_account_key`, `network`, `platform_user_id`, `platform`, +`provider_post_id`, `provider_published_at`, `origin`, `content_type`, and +`availability`; nullable provider content type, permalink, excerpt, preview +metadata, account presentation snapshots, first/last seen times, +provider-synced time, and provider metadata JSON. Its provider identity unique +key is `workspace_id, social_account_key, network, provider_post_id`. + +`analytics_publication_daily_snapshots` has only its UUID, non-null parent +`analytics_publication_id` with cascade delete, `snapshot_date`, `collected_at`, +nullable `provider_observed_at`, nullable metric-catalog JSON, and nullable +portable projections: reactions, comments, shares, saves, views, impressions, +reach, engagement, exposure, exposure kind, total watch milliseconds, and +average watch milliseconds. It deliberately has no duplicate `workspace_id`. +Its unique key is `analytics_publication_id, snapshot_date`. + +`analytics_sync_states` has a non-null `social_account_id` with cascade delete, +collector, status, nullable provider-specific `checkpoint` JSON, +`target_since`, `oldest_reached_at`, `high_watermark_at`, `last_success_at`, +sanitized `last_error_category`, and timestamps. It deliberately has no +workspace/account-history copies, attempt count, retry timestamp, or raw error +message. Its unique key is `social_account_id, collector`. + +Only snapshots and publications store `network` plus `platform_user_id`, because +they are historical identity. Operational sync state is tied to the live row and +is recreated on reconnect. + +Add these query indexes: + +```php +$table->index(['workspace_id', 'snapshot_date']); +$table->index(['workspace_id', 'social_account_key', 'snapshot_date']); +$table->index(['workspace_id', 'provider_published_at']); +$table->index(['workspace_id', 'social_account_key', 'provider_published_at']); +$table->index(['analytics_publication_id', 'collected_at']); +$table->index(['collector', 'status']); +``` + +Historical `social_account_id` and `post_platform_id` use `nullOnDelete()`; +sync-state `social_account_id` and every `workspace_id` use +`cascadeOnDelete()`. Keep provider ids as bounded strings, metric counters and +canonical durations as nullable big integers, precise rates as nullable +decimals, timestamps below the MySQL 2038 ceiling, and JSON object assertions +order-independent. Test that a publication snapshot cannot carry a tenant id +different from its parent because no such child column exists. + +- [ ] **Step 5: Run schema tests on the configured database** + +Run: `php artisan test --compact tests/Feature/Analytics/AnalyticsSchemaTest.php` + +Expected: PASS. + +- [ ] **Step 6: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Enums/Analytics database/migrations tests/Feature/Analytics/AnalyticsSchemaTest.php +git commit -m "feat: add workspace analytics schema" +``` + +### Task 2: Add analytics models, factories, DTOs, and idempotent writers + +**Files:** +- Create: `app/Models/AnalyticsAccountDailySnapshot.php` +- Create: `app/Models/AnalyticsPublication.php` +- Create: `app/Models/AnalyticsPublicationDailySnapshot.php` +- Create: `app/Models/AnalyticsSyncState.php` +- Create: `database/factories/AnalyticsAccountDailySnapshotFactory.php` +- Create: `database/factories/AnalyticsPublicationFactory.php` +- Create: `database/factories/AnalyticsPublicationDailySnapshotFactory.php` +- Create: `database/factories/AnalyticsSyncStateFactory.php` +- Create: `app/Dto/Analytics/AccountDailyObservation.php` +- Create: `app/Dto/Analytics/MetricValue.php` +- Create: `app/Dto/Analytics/PublicationMetricObservation.php` +- Create: `app/Actions/Analytics/WriteAccountDailySnapshot.php` +- Create: `app/Actions/Analytics/WritePublicationDailySnapshot.php` +- Create: `app/Actions/Analytics/ResolveAnalyticsAccountKey.php` +- Test: `tests/Feature/Analytics/AnalyticsObservationWriterTest.php` + +**Interfaces:** +- Consumes: Task 1 tables and enums. +- Produces: `ResolveAnalyticsAccountKey::for(SocialAccount $account): string`, `WriteAccountDailySnapshot::handle(SocialAccount $account, AccountDailyObservation $observation): AnalyticsAccountDailySnapshot`, and `WritePublicationDailySnapshot::handle(AnalyticsPublication $publication, PublicationMetricObservation $observation): AnalyticsPublicationDailySnapshot`. + +- [ ] **Step 1: Generate models, factories, actions, and the failing writer test** + +Run: + +```bash +php artisan make:model AnalyticsAccountDailySnapshot --factory --no-interaction +php artisan make:model AnalyticsPublication --factory --no-interaction +php artisan make:model AnalyticsPublicationDailySnapshot --factory --no-interaction +php artisan make:model AnalyticsSyncState --factory --no-interaction +php artisan make:class Dto/Analytics/AccountDailyObservation --no-interaction +php artisan make:class Dto/Analytics/MetricValue --no-interaction +php artisan make:class Dto/Analytics/PublicationMetricObservation --no-interaction +php artisan make:class Actions/Analytics/ResolveAnalyticsAccountKey --no-interaction +php artisan make:class Actions/Analytics/WriteAccountDailySnapshot --no-interaction +php artisan make:class Actions/Analytics/WritePublicationDailySnapshot --no-interaction +php artisan make:test --pest Analytics/AnalyticsObservationWriterTest --no-interaction +``` + +The test must prove same-day writes update one row, next-day writes create history, null stays null, numeric zero stays zero, deleting the social account preserves snapshots through the immutable key, reconnecting the same workspace + network + `platform_user_id` reuses that key, and a different provider identity receives a new key even if the username is identical. + +```php +$writer->handle($account, new AccountDailyObservation( + date: CarbonImmutable::parse('2026-09-23', 'UTC'), + followers: 0, + provenance: ObservationProvenance::Actual, + precision: MetricPrecision::Exact, + providerObservedAt: null, +)); + +expect(AnalyticsAccountDailySnapshot::count())->toBe(1) + ->and(AnalyticsAccountDailySnapshot::first()->followers_count)->toBe(0); +``` + +- [ ] **Step 2: Run the writer test and verify it fails** + +Run: `php artisan test --compact tests/Feature/Analytics/AnalyticsObservationWriterTest.php` + +Expected: FAIL because the models and writers do not exist. + +- [ ] **Step 3: Implement typed DTOs and model relationships** + +`MetricValue` is the JSON boundary: + +```php +final readonly class MetricValue +{ + public function __construct( + public MetricKey $key, + public int|float|null $value, + public MetricUnit $unit, + public MetricTimeBasis $timeBasis, + public MetricPrecision $precision, + public MetricAvailability $availability, + public ?string $providerMetric = null, + public ?CarbonImmutable $periodStart = null, + public ?CarbonImmutable $periodEnd = null, + ) {} +} +``` + +Models use `HasUuids`, `HasFactory`, explicit `$fillable`, enum/date/array casts, and typed `belongsTo`/`hasMany` relationships. Add relationships from `Workspace`, `SocialAccount`, and `PostPlatform` only when a later query uses them. + +- [ ] **Step 4: Implement transactional upsert writers** + +Use the unique business keys rather than process-local locks. +`ResolveAnalyticsAccountKey` searches historical account snapshots and +publications by workspace + `Platform::network()` + `platform_user_id`, and +otherwise returns the current social-account UUID. Sync state is operational +and is never an identity source. Snapshot presentation fields come from the +account at write time. + +The publication writer locks the same-day row and atomically merges the metric +catalog and scalar projections. A collector response may update the metrics it +actually observed, but a missing/unsupported/delayed value never blanks a prior +successful same-day value and never becomes zero. The test compares every +scalar projection against its canonical JSON entry so the two representations +cannot drift. A carried-forward follower snapshot retains the original +`provider_observed_at` while recording its new `collected_at`, so staleness is +not hidden. + +```php +return AnalyticsAccountDailySnapshot::query()->updateOrCreate( + [ + 'workspace_id' => $account->workspace_id, + 'social_account_key' => $this->accountKeys->for($account), + 'snapshot_date' => $observation->date->toDateString(), + ], + $this->attributes($account, $observation), +); +``` + +- [ ] **Step 5: Run writer tests** + +Run: `php artisan test --compact tests/Feature/Analytics/AnalyticsObservationWriterTest.php` + +Expected: PASS. + +- [ ] **Step 6: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Models app/Dto/Analytics app/Actions/Analytics database/factories tests/Feature/Analytics/AnalyticsObservationWriterTest.php +git commit -m "feat: persist analytics observations" +``` + +### Task 3: Normalize follower collection for every included platform + +**Files:** +- Create: `app/Contracts/Analytics/FollowerCollector.php` +- Create: `app/Exceptions/Analytics/AnalyticsCollectionException.php` +- Create: `app/Services/Analytics/Collectors/Followers/FollowerCollectorFactory.php` +- Create: `app/Services/Analytics/Collectors/Followers/InstagramFollowerCollector.php` +- Create: `app/Services/Analytics/Collectors/Followers/FacebookFollowerCollector.php` +- Create: `app/Services/Analytics/Collectors/Followers/ThreadsFollowerCollector.php` +- Create: `app/Services/Analytics/Collectors/Followers/XFollowerCollector.php` +- Create: `app/Services/Analytics/Collectors/Followers/PinterestFollowerCollector.php` +- Create: `app/Services/Analytics/Collectors/Followers/YouTubeFollowerCollector.php` +- Create: `app/Services/Analytics/Collectors/Followers/TikTokFollowerCollector.php` +- Create: `app/Services/Analytics/Collectors/Followers/BlueskyFollowerCollector.php` +- Create: `app/Services/Analytics/Collectors/Followers/MastodonFollowerCollector.php` +- Test: `tests/Feature/Analytics/Collectors/FollowerCollectorsTest.php` + +**Interfaces:** +- Consumes: `SocialAccount` and Task 2 `AccountDailyObservation`. +- Produces: `FollowerCollector::collect(SocialAccount $account, CarbonImmutable $date): AccountDailyObservation` and `FollowerCollectorFactory::for(Platform $platform): FollowerCollector`. + +- [ ] **Step 1: Write the contract and failing provider dataset** + +```php +interface FollowerCollector +{ + public function collect(SocialAccount $account, CarbonImmutable $date): AccountDailyObservation; +} +``` + +Use one Pest dataset that fakes and verifies these read paths and canonical fields: + +| Platform | Read path | Field | +| --- | --- | --- | +| Instagram variants | Graph user/profile or account insights supported by login type | `followers_count` | +| Facebook Page | Graph Page | `followers_count` | +| Threads | user insights | `followers_count` | +| X | `/2/users/{id}?user.fields=public_metrics` | `public_metrics.followers_count` | +| Pinterest | `/v5/user_account` | `follower_count` | +| YouTube | `channels.list(part=statistics)` | `subscriberCount`, approximate; null if hidden | +| TikTok | `/v2/user/info/?fields=follower_count` | `follower_count` | +| Bluesky | `app.bsky.actor.getProfile` | `followersCount` | +| Mastodon | `/api/v1/accounts/{id}` | `followers_count` | + +The test must also assert excluded platforms make no request and that missing fields throw an unavailable collection result rather than returning zero. + +- [ ] **Step 2: Run the collector test and verify it fails** + +Run: `php artisan test --compact tests/Feature/Analytics/Collectors/FollowerCollectorsTest.php` + +Expected: FAIL because the collector factory is missing. + +- [ ] **Step 3: Implement failure classification and provider collectors** + +`AnalyticsCollectionException` carries `transient`, `rate_limited`, `authentication`, `permission`, `unsupported`, or `malformed`, plus nullable provider retry time. Reuse existing token refresh and Graph error classification where available; never log response bodies containing tokens. + +The factory has an explicit match for the ten included platform values and throws for LinkedIn, Telegram, Discord, and Google Business. Instagram direct and Facebook-login variants share the collector class but branch on the existing platform value. + +- [ ] **Step 4: Run collector tests** + +Run: `php artisan test --compact tests/Feature/Analytics/Collectors/FollowerCollectorsTest.php` + +Expected: PASS with `Http::assertSent` endpoint and field verification for every platform. + +- [ ] **Step 5: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Contracts/Analytics app/Exceptions/Analytics app/Services/Analytics/Collectors/Followers tests/Feature/Analytics/Collectors/FollowerCollectorsTest.php +git commit -m "feat: collect normalized follower snapshots" +``` + +### Task 4: Queue daily followers, same-day retries, and carry-forward + +**Files:** +- Create: `app/Jobs/Analytics/CollectAccountDailySnapshot.php` +- Create: `app/Jobs/Analytics/FinalizeAccountDailySnapshots.php` +- Create: `app/Console/Commands/Analytics/DispatchAccountDailyAnalytics.php` +- Modify: `routes/console.php` +- Modify: `app/Observers/SocialAccountObserver.php` +- Test: `tests/Feature/Analytics/AccountDailyJobsTest.php` +- Test: `tests/Feature/Analytics/AnalyticsScheduleTest.php` +- Modify: `tests/Feature/Observers/SocialAccountObserverTest.php` + +**Interfaces:** +- Consumes: Task 3 collectors and Task 2 account writer. +- Produces: one actual or carried-forward row per eligible account/day and immediate collection after connection. + +- [ ] **Step 1: Generate jobs/command and write failing dispatch tests** + +Test included/excluded platforms, inactive/disconnected accounts, duplicate-network accounts, immediate post-commit dispatch, `analytics` queue selection, and scheduler guards. + +```php +Bus::assertDispatched(CollectAccountDailySnapshot::class, + fn ($job) => $job->socialAccountId === $instagram->id + && $job->observationDate === '2026-09-23'); +Bus::assertNotDispatched(CollectAccountDailySnapshot::class, + fn ($job) => $job->socialAccountId === $linkedin->id); +``` + +- [ ] **Step 2: Run queue tests and verify they fail** + +Run: `php artisan test --compact tests/Feature/Analytics/AccountDailyJobsTest.php tests/Feature/Analytics/AnalyticsScheduleTest.php tests/Feature/Observers/SocialAccountObserverTest.php` + +Expected: FAIL because jobs and schedules are absent. + +- [ ] **Step 3: Implement bounded dispatch and job isolation** + +The command uses `lazyById(200)` and dispatches IDs only. The job re-queries the social account, revalidates active/connected/included status, uses `WithoutOverlapping` keyed by account/date, and exits if an actual row already exists. + +On a transient/rate-limit exception, release near the next `06:00`, `10:00`, `14:00`, `18:00`, or `22:00` UTC window, honoring a later provider time inside the same UTC day. Authentication/permission errors stop the analytics retry without writing a value or mutating the account's publishing status. Existing connection verification owns token-health transitions. Set job `retryUntil()` to the end of its observation day. + +Set `tries = 6` for the initial `02:00` attempt plus the five delayed windows. +Because `release()` consumes an attempt, calculate the next window from the +observation date and current attempt rather than using a fast `backoff()` array. +If `Retry-After` points beyond the UTC day, stop retrying and let the finalizer +decide whether a historical value exists. + +- [ ] **Step 4: Implement end-of-day fallback** + +`FinalizeAccountDailySnapshots` iterates eligible accounts without an actual +row. It copies the last non-null follower count into the current date with +`CarriedForward`, preserves the source row's original `provider_observed_at`, +and records a new `collected_at`. It writes nothing when history is absent and +never overwrites an actual row, including when a late successful job races the +finalizer. + +- [ ] **Step 5: Schedule and observer integration** + +Schedule the dispatch command at `02:00` UTC and finalizer at `23:30` UTC, +after the last retry window, both with `withoutOverlapping()` and +`onOneServer()`. Dispatch initial collection `afterCommit()` when an included +account becomes connected; observers must never throw during delete/reconnect. + +- [ ] **Step 6: Run queue/schedule tests** + +Run: `php artisan test --compact tests/Feature/Analytics/AccountDailyJobsTest.php tests/Feature/Analytics/AnalyticsScheduleTest.php tests/Feature/Observers/SocialAccountObserverTest.php` + +Expected: PASS. + +- [ ] **Step 7: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Jobs/Analytics app/Console/Commands/Analytics routes/console.php app/Observers/SocialAccountObserver.php tests/Feature/Analytics tests/Feature/Observers/SocialAccountObserverTest.php +git commit -m "feat: schedule resilient follower analytics" +``` + +### Task 5: Reconcile TryPost and external publications into one catalog + +**Files:** +- Create: `app/Dto/Analytics/DiscoveredPublication.php` +- Create: `app/Dto/Analytics/TryPostPublicationIdentity.php` +- Create: `app/Actions/Analytics/UpsertAnalyticsPublication.php` +- Create: `app/Actions/Analytics/SyncTryPostPublication.php` +- Create: `app/Jobs/Analytics/SyncTryPostPublication.php` +- Modify: `app/Observers/PostPlatformObserver.php` +- Create: `tests/Feature/Analytics/PublicationReconciliationTest.php` +- Modify: `tests/Feature/Observers/PostPlatformObserverTest.php` + +**Interfaces:** +- Consumes: Task 2 `AnalyticsPublication` and existing published `PostPlatform`. +- Produces: `UpsertAnalyticsPublication::external(SocialAccount $account, DiscoveredPublication $publication): AnalyticsPublication` and `SyncTryPostPublication::handle(PostPlatform $postPlatform): AnalyticsPublication`. + +- [ ] **Step 1: Write failing reconciliation tests for both arrival orders** + +Test external-first/TryPost-second, TryPost-first/external-second, duplicate +provider pages, same provider id on two social accounts, workspace isolation, +and deletion of the social account after the job is dispatched but before it +runs. + +```php +expect(AnalyticsPublication::query()->where('provider_post_id', 'remote-1')->count())->toBe(1) + ->and(AnalyticsPublication::first()->origin)->toBe(PublicationOrigin::TryPost) + ->and(AnalyticsPublication::first()->post_platform_id)->toBe($postPlatform->id); +``` + +- [ ] **Step 2: Run reconciliation tests and verify they fail** + +Run: `php artisan test --compact tests/Feature/Analytics/PublicationReconciliationTest.php tests/Feature/Observers/PostPlatformObserverTest.php` + +Expected: FAIL because catalog actions/jobs are absent. + +- [ ] **Step 3: Implement the discovery DTO and transactional upsert** + +`DiscoveredPublication` carries provider id, publication time, +normalized/provider content type, permalink, excerpt, preview metadata, and +provider metadata. Resolve the historical account key first, then lock the +provider identity row using workspace + key + normalized network + provider +post id. `trypost` origin wins; provider publication time never becomes +discovery time; presentation snapshots update only with non-null values. + +A lock cannot protect a row that does not exist yet. Treat the database unique +constraint as the final concurrency arbiter: attempt the insert, catch only the +unique-constraint collision, reload the winning row under lock, and merge. Test +that simultaneous discovery and TryPost sync converge without swallowing any +other database error. + +`TryPostPublicationIdentity` is a token-free primitive snapshot captured while +the live account still exists: workspace id, social-account id, resolved +historical key, normalized network, provider account id, platform, and account +presentation. The queued local-catalog sync receives this DTO plus the +post-platform id. This closes the race where a user deletes the account after +dispatch but before the job runs; the job must not depend on reloading the live +account to establish historical identity. + +- [ ] **Step 4: Dispatch catalog sync after a destination becomes published** + +Extend `PostPlatformObserver` independently of the PostHog flag: whenever +status changes to `Published` and `platform_post_id` is present on an included +platform, resolve the identity snapshot and dispatch +`App\Jobs\Analytics\SyncTryPostPublication` with that snapshot and the +post-platform id using `afterCommit()`. The observer remains non-throwing: a +local sync dispatch failure is reported and repaired by the rollout/daily local +reconciliation command. + +- [ ] **Step 5: Run reconciliation/observer tests** + +Run: `php artisan test --compact tests/Feature/Analytics/PublicationReconciliationTest.php tests/Feature/Observers/PostPlatformObserverTest.php` + +Expected: PASS. + +- [ ] **Step 6: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Dto/Analytics app/Actions/Analytics app/Jobs/Analytics/SyncTryPostPublication.php app/Observers/PostPlatformObserver.php tests/Feature/Analytics/PublicationReconciliationTest.php tests/Feature/Observers/PostPlatformObserverTest.php +git commit -m "feat: reconcile analytics publications" +``` + +### Task 6: Add owned-publication collector contracts and Meta/Threads adapters + +**Files:** +- Create: `app/Contracts/Analytics/PublicationHistoryCollector.php` +- Create: `app/Dto/Analytics/PublicationPage.php` +- Create: `app/Services/Analytics/Collectors/Publications/PublicationHistoryCollectorFactory.php` +- Create: `app/Services/Analytics/Collectors/Publications/InstagramPublicationCollector.php` +- Create: `app/Services/Analytics/Collectors/Publications/FacebookPublicationCollector.php` +- Create: `app/Services/Analytics/Collectors/Publications/ThreadsPublicationCollector.php` +- Test: `tests/Feature/Analytics/Collectors/MetaPublicationCollectorsTest.php` + +**Interfaces:** +- Consumes: Task 5 `DiscoveredPublication`. +- Produces: `PublicationHistoryCollector::page(SocialAccount $account, ?string $cursor, CarbonImmutable $cutoff): PublicationPage`. + +- [ ] **Step 1: Define the page contract and failing cursor tests** + +```php +final readonly class PublicationPage +{ + /** @param list $publications */ + public function __construct( + public array $publications, + public ?string $nextCursor, + public bool $providerExhausted, + public bool $providerLimited = false, + ) {} +} +``` + +Tests must prove: Instagram paginates `/media` for feed/carousel/Reels but does not claim expired Stories; Facebook uses Page-owned published posts plus required video/Reel hydration without visitor posts; Threads paginates owned posts; timestamps stop at but do not cross the cutoff; preview failure does not drop the publication. + +- [ ] **Step 2: Run Meta collector tests and verify they fail** + +Run: `php artisan test --compact tests/Feature/Analytics/Collectors/MetaPublicationCollectorsTest.php` + +Expected: FAIL because collectors are absent. + +- [ ] **Step 3: Implement one-page Meta adapters** + +Each call reads exactly one provider page and returns the provider cursor without dispatching or persisting. Share only a low-level Graph client/error parser with Repurpose; do not call `PollRepurposeSource`, create `RepurposeItem`, or download media. Parse Instagram direct and Facebook-login field differences explicitly. + +- [ ] **Step 4: Run Meta collector tests** + +Run: `php artisan test --compact tests/Feature/Analytics/Collectors/MetaPublicationCollectorsTest.php` + +Expected: PASS. + +- [ ] **Step 5: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Contracts/Analytics app/Dto/Analytics/PublicationPage.php app/Services/Analytics/Collectors/Publications tests/Feature/Analytics/Collectors/MetaPublicationCollectorsTest.php +git commit -m "feat: discover Meta analytics publications" +``` + +### Task 7: Add X, Pinterest, YouTube, and TikTok publication adapters + +**Files:** +- Create: `app/Services/Analytics/Collectors/Publications/XPublicationCollector.php` +- Create: `app/Services/Analytics/Collectors/Publications/PinterestPublicationCollector.php` +- Create: `app/Services/Analytics/Collectors/Publications/YouTubePublicationCollector.php` +- Create: `app/Services/Analytics/Collectors/Publications/TikTokPublicationCollector.php` +- Modify: `app/Services/Analytics/Collectors/Publications/PublicationHistoryCollectorFactory.php` +- Test: `tests/Feature/Analytics/Collectors/MediaPublicationCollectorsTest.php` + +**Interfaces:** +- Consumes/produces: Task 6 history contract and page DTO. + +- [ ] **Step 1: Write failing endpoint, pagination, and capability tests** + +Cover X `users/:id/tweets` next tokens and paid-read fields; Pinterest `/v5/pins` bookmarks; YouTube uploads-playlist page tokens followed by batched `videos.list`; TikTok `video.list` cursor with maximum 20. TikTok without `video.list` must return provider-limited coverage, not fail account connection. YouTube imports all uploads as `Video` unless the provider gives an authoritative type; do not infer Shorts from duration or aspect ratio. + +- [ ] **Step 2: Run collector tests and verify they fail** + +Run: `php artisan test --compact tests/Feature/Analytics/Collectors/MediaPublicationCollectorsTest.php` + +Expected: FAIL because collectors are absent. + +- [ ] **Step 3: Implement bounded provider pages and ephemeral preview handling** + +Never persist TikTok cover URLs as durable truth: store them as provider preview metadata with `expires_at`, and let UI fallback when expired. Pinterest records provider metric time-basis metadata. X requests only fields required by the catalog to control read cost. +X's official user-post timeline exposes at most the [3,200 most recent posts](https://docs.x.com/x-api/posts/timelines/introduction). The backfill checkpoint counts committed X publications across pages; if it exhausts at that cap before the 365-day target, mark coverage `provider_limited` with `x_timeline_3200`, not `complete`. Resume a failed cursor without resetting the count, but reset it when restarting from the first page. This adds no table and requires no extra paid read. + +- [ ] **Step 4: Run collector tests** + +Run: `php artisan test --compact tests/Feature/Analytics/Collectors/MediaPublicationCollectorsTest.php` + +Expected: PASS. + +- [ ] **Step 5: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Services/Analytics/Collectors/Publications tests/Feature/Analytics/Collectors/MediaPublicationCollectorsTest.php +git commit -m "feat: discover media network publications" +``` + +### Task 8: Add Bluesky and Mastodon publication adapters + +**Files:** +- Create: `app/Services/Analytics/Collectors/Publications/BlueskyPublicationCollector.php` +- Create: `app/Services/Analytics/Collectors/Publications/MastodonPublicationCollector.php` +- Modify: `app/Services/Analytics/Collectors/Publications/PublicationHistoryCollectorFactory.php` +- Modify: `app/Http/Controllers/Auth/MastodonController.php` +- Test: `tests/Feature/Analytics/Collectors/OpenPublicationCollectorsTest.php` +- Modify: `tests/Feature/Auth/MastodonOAuthTest.php` + +**Interfaces:** +- Consumes/produces: Task 6 history contract and page DTO. + +- [ ] **Step 1: Write failing Bluesky repository and Mastodon scope tests** + +Bluesky must page `com.atproto.repo.listRecords` for `app.bsky.feed.post` and hydrate batches for public counts instead of trusting `getAuthorFeed` completeness. Mastodon must page `/api/v1/accounts/{id}/statuses` with `max_id`; private/complete history requires `read:statuses`. Existing accounts without that scope remain public-history/partial rather than failing. + +- [ ] **Step 2: Run tests and verify they fail** + +Run: `php artisan test --compact tests/Feature/Analytics/Collectors/OpenPublicationCollectorsTest.php tests/Feature/Auth/MastodonOAuthTest.php` + +Expected: FAIL because collectors and the scope change are absent. + +- [ ] **Step 3: Implement adapters and request `read:statuses` for new Mastodon connections** + +Keep instance URLs account-specific and validate them through the existing connection flow. Mark existing insufficient-scope imports `Partial` with a reconnect hint; never silently claim complete private history. + +- [ ] **Step 4: Run tests** + +Run: `php artisan test --compact tests/Feature/Analytics/Collectors/OpenPublicationCollectorsTest.php tests/Feature/Auth/MastodonOAuthTest.php` + +Expected: PASS. + +- [ ] **Step 5: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Services/Analytics/Collectors/Publications app/Http/Controllers/Auth/MastodonController.php tests/Feature/Analytics/Collectors/OpenPublicationCollectorsTest.php tests/Feature/Auth/MastodonOAuthTest.php +git commit -m "feat: discover open network publications" +``` + +### Task 9: Run resumable account backfill and daily publication discovery entirely through jobs + +**Files:** +- Create: `app/Jobs/Analytics/BootstrapAccountAnalytics.php` +- Create: `app/Jobs/Analytics/BackfillAccountPublications.php` +- Create: `app/Jobs/Analytics/DiscoverAccountPublications.php` +- Create: `app/Jobs/Analytics/BackfillTryPostPublications.php` +- Create: `app/Console/Commands/Analytics/DispatchPublicationDiscovery.php` +- Create: `app/Console/Commands/Analytics/BackfillExistingAnalytics.php` +- Create: `app/Actions/Analytics/AdvanceAnalyticsSyncState.php` +- Modify: `app/Observers/SocialAccountObserver.php` +- Modify: `routes/console.php` +- Test: `tests/Feature/Analytics/PublicationBackfillJobsTest.php` +- Test: `tests/Feature/Analytics/BackfillExistingAnalyticsCommandTest.php` + +**Interfaces:** +- Consumes: Tasks 5–8 catalog action, collectors, and sync-state model. +- Produces: resumable 365-day initial import, local TryPost catalog seeding, and overlapping daily discovery. + +- [ ] **Step 1: Write failing job-chain and resume tests** + +Test one provider page per execution, separate backfill/discovery state rows, +cursor committed only after publication upserts, continuation dispatch after +commit, duplicate job idempotency, stale checkpoint version rejection, account +deletion cascading operational state only, reconnect creating fresh state while +reusing historical publication identity, failure resume, 365-day stop, +exhausted stop, provider-limited stop, overlap window, existing-account rollout +chunking, and per-account isolation. Assert daily discovery is suppressed while +backfill is pending/running and enabled after every terminal backfill state. + +```php +Bus::assertDispatched(BackfillAccountPublications::class, + fn ($job) => $job->socialAccountId === $account->id); +expect($state->fresh()->checkpoint['cursor'])->toBe('provider-next-page') + ->and($state->fresh()->status)->toBe(SyncStatus::Running); +``` + +- [ ] **Step 2: Run backfill tests and verify they fail** + +Run: `php artisan test --compact tests/Feature/Analytics/PublicationBackfillJobsTest.php tests/Feature/Analytics/BackfillExistingAnalyticsCommandTest.php` + +Expected: FAIL because jobs and command are absent. + +- [ ] **Step 3: Implement sync-state locking and bounded jobs** + +Jobs carry account/state ids only and use a provider-specific queue limiter. +Inside a short transaction, lock the sync-state row, capture its checkpoint and +`updated_at` version, and mark it running. Fetch exactly one provider page +outside the transaction. In a second transaction, lock the state again and +upsert the returned publications idempotently. Advance `checkpoint`, coverage, +and high-water fields only if the captured checkpoint/version still matches; +otherwise leave progress untouched. A stale duplicate may safely reconcile +facts but can never move the cursor backward. + +The sync table has no retry counters or timestamps: queue attempts, classified +delays, failed-job storage, and Horizon are authoritative. Persist only the +sanitized latest error category needed to explain coverage in the UI; never a +raw provider body. + +Set `target_since` to the bootstrap time minus 365 days for initial history. +Daily discovery uses the high-water mark minus a fixed overlap window and the +same provider identity unique key. Persist `oldest_reached_at`, +`last_success_at`, and a truthful final status. A provider listing omission is +not proof of deletion: mark a publication deleted/unavailable only on an +explicit provider response for that publication. + +When backfill first becomes terminal, initialize discovery from the newest +provider publication already stored for that account, falling back to the +current time only when the catalog is empty. If a provider invalidates an old +cursor, clear only that cursor and restart from `oldest_reached_at` plus an +overlap window; idempotent publication identity prevents duplicates and the +365-day target remains unchanged. + +- [ ] **Step 4: Implement rollout and local TryPost backfill** + +`analytics:backfill-existing` first uses `lazyById(100)` to dispatch +`BackfillTryPostPublications` for published included destinations with a live +social account, then dispatches `BootstrapAccountAnalytics` for active included +accounts. The command itself performs no provider calls and accepts an optional +workspace id for controlled rollout. + +Pre-rollout published destinations whose `social_account_id` is already null +are counted and logged as `historical_identity_unrecoverable`; they are not +merged by username and no synthetic account key is invented. This limitation +applies only to facts orphaned before the analytics catalog exists. The command +is repeatable, and discovery later reconciles any reachable provider post by +its real account identity. + +- [ ] **Step 5: Connect observer and schedule** + +On included account creation/reconnection, dispatch `BootstrapAccountAnalytics` after commit. Schedule daily discovery dispatch with `withoutOverlapping()` and `onOneServer()`. + +- [ ] **Step 6: Run backfill tests** + +Run: `php artisan test --compact tests/Feature/Analytics/PublicationBackfillJobsTest.php tests/Feature/Analytics/BackfillExistingAnalyticsCommandTest.php tests/Feature/Observers/SocialAccountObserverTest.php` + +Expected: PASS. + +- [ ] **Step 7: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Jobs/Analytics app/Console/Commands/Analytics app/Actions/Analytics app/Observers/SocialAccountObserver.php routes/console.php tests/Feature/Analytics tests/Feature/Observers/SocialAccountObserverTest.php +git commit -m "feat: backfill native analytics through jobs" +``` + +### Task 10: Normalize and persist post metrics for all included networks + +**Files:** +- Create: `app/Contracts/Analytics/PublicationMetricsCollector.php` +- Create: `app/Services/Analytics/Collectors/Metrics/PublicationMetricsCollectorFactory.php` +- Create: one `*PublicationMetricsCollector.php` in that directory for Instagram, Facebook, Threads, X, Pinterest, YouTube, TikTok, Bluesky, and Mastodon +- Refactor: `app/Services/Social/InstagramAnalytics.php` +- Refactor: `app/Services/Social/FacebookAnalytics.php` +- Refactor: `app/Services/Social/ThreadsAnalytics.php` +- Refactor: `app/Services/Social/XAnalytics.php` +- Refactor: `app/Services/Social/PinterestAnalytics.php` +- Refactor: `app/Services/Social/YouTubeAnalytics.php` +- Refactor: `app/Services/Social/TikTokAnalytics.php` +- Refactor: `app/Services/Social/BlueskyAnalytics.php` +- Refactor: `app/Services/Social/MastodonAnalytics.php` +- Test: `tests/Feature/Analytics/Collectors/PublicationMetricsCollectorsTest.php` + +**Interfaces:** +- Consumes: `AnalyticsPublication` and Task 2 metric DTOs. +- Produces: `PublicationMetricsCollector::collect(AnalyticsPublication $publication, CarbonImmutable $date): PublicationMetricObservation`. + +- [ ] **Step 1: Write failing normalized-metric datasets** + +The dataset must pin the exact mapping from the spec: + +- Instagram feed/Reel/Story common engagement, reach/views, Reel watch time/average/skip, and Story navigation; parse both `values[].value` and `total_value.value`. +- Facebook Page publication reactions, comments, shares, impressions/reach, and video metrics when supported. +- Threads views, likes, replies, reposts, and quotes. +- X impressions, likes, reposts, replies, quotes, bookmarks, and 30-day-only private/video fields when available. +- Pinterest image/video Pin metrics with lifetime/range/rolling basis retained and batch-ready IDs. +- YouTube views, engaged views, watch time, average duration/percentage, likes, comments, shares, subscriber gains/losses. +- TikTok views, likes, comments, shares and no fabricated retention. +- Bluesky likes, replies, reposts, quotes. +- Mastodon favourites, replies, reblogs. + +For every provider, include measured zero, omitted, unsupported, malformed, rate-limited, and stale-value-preservation cases. + +- [ ] **Step 2: Run metric collector tests and verify they fail** + +Run: `php artisan test --compact tests/Feature/Analytics/Collectors/PublicationMetricsCollectorsTest.php` + +Expected: FAIL because normalized collectors are absent. + +- [ ] **Step 3: Implement stable normalized observations** + +```php +interface PublicationMetricsCollector +{ + public function collect( + AnalyticsPublication $publication, + CarbonImmutable $date, + ): PublicationMetricObservation; +} +``` + +Split Meta metric families that cannot share one request. Store all durations +canonically as integer milliseconds and convert only at presentation time. +Compute normalized engagement numerator from supported interaction components +and preserve `exposure_count` plus `ExposureKind`; do not store a provider +engagement rate as if it were the normalized TryPost rate. The writer updates +the JSON catalog and every corresponding scalar projection in one transaction. + +Refactor existing service methods to share low-level authenticated requests/parsers where safe, but do not return translated labels to persistence. Excluded providers remain callable by legacy code until Task 13 removes their analytics read paths, but the new factory never returns them. + +- [ ] **Step 4: Run metric collector and existing provider tests** + +Run: + +```bash +php artisan test --compact tests/Feature/Analytics/Collectors/PublicationMetricsCollectorsTest.php tests/Feature/Services/Social tests/Feature/XAnalyticsTest.php tests/Feature/YouTubeAnalyticsTest.php +``` + +Expected: PASS. + +- [ ] **Step 5: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Contracts/Analytics app/Services/Analytics/Collectors/Metrics app/Services/Social tests/Feature/Analytics/Collectors/PublicationMetricsCollectorsTest.php tests/Feature/Services/Social +git commit -m "feat: normalize publication analytics metrics" +``` + +### Task 11: Queue metric refresh, baseline backfill, and Story lifecycle collection + +**Files:** +- Create: `app/Jobs/Analytics/CollectPublicationMetrics.php` +- Create: `app/Console/Commands/Analytics/DispatchPublicationMetrics.php` +- Create: `app/Jobs/Analytics/ScheduleInstagramStoryMetrics.php` +- Modify: `app/Jobs/Analytics/BackfillAccountPublications.php` +- Modify: `app/Jobs/Analytics/DiscoverAccountPublications.php` +- Modify: `routes/console.php` +- Test: `tests/Feature/Analytics/PublicationMetricsJobsTest.php` + +**Interfaces:** +- Consumes: Task 10 collector factory and Task 2 publication writer. +- Produces: latest persisted metrics with 20-day X, 30-day other-network, one-time old backfill baseline, and sub-day Story collection. + +- [ ] **Step 1: Write failing eligibility and retry tests** + +Test included/excluded platforms, usable provider ids, active access, X day 20/day 21, other day 30/day 31, final-day collection, old imported baseline exactly once, no perpetual old refresh, same-day idempotency, latest-value preservation on failure, batching eligibility, and Story immediate/pre-expiry runs. + +- [ ] **Step 2: Run job tests and verify they fail** + +Run: `php artisan test --compact tests/Feature/Analytics/PublicationMetricsJobsTest.php` + +Expected: FAIL because jobs are absent. + +- [ ] **Step 3: Implement metric job and dispatcher** + +The job re-queries publication and live account, skips stale/excluded rows, +collects, then writes one daily snapshot. Use the same classified same-day retry +policy as followers. Default to a bounded batch job per account/provider; use a +single publication for endpoints that do not batch and the documented provider +maximum for Pinterest, TikTok, and YouTube. Persist successful items before +retrying only failed items, so one bad id never discards a whole successful +batch. + +- [ ] **Step 4: Implement import handoff and Story schedule** + +New publications inside the refresh window dispatch normal collection. Older +backfill rows dispatch one baseline job only when that publication has no +snapshot; baseline completion is therefore proved by the fact table, not sync +metadata. Instagram Stories dispatch immediately, at configured within-lifetime +checkpoints, and once shortly before expiry; all writes converge on the daily +writer. A delayed insight must not be mistaken for unsupported, and Story jobs +stop after the provider availability window. + +- [ ] **Step 5: Schedule daily metric dispatch and run tests** + +Run: `php artisan test --compact tests/Feature/Analytics/PublicationMetricsJobsTest.php tests/Feature/Analytics/AnalyticsScheduleTest.php` + +Expected: PASS. + +- [ ] **Step 6: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Jobs/Analytics app/Console/Commands/Analytics routes/console.php tests/Feature/Analytics +git commit -m "feat: schedule persisted publication metrics" +``` + +### Task 12: Build the portable workspace analytics read model + +**Files:** +- Create: `app/Dto/Analytics/DateRange.php` +- Create: `app/Actions/Analytics/BuildWorkspaceAnalyticsReport.php` +- Create: `app/Actions/Analytics/BuildPublicationAnalyticsReport.php` +- Create: `app/Actions/Analytics/BuildFollowerAnalyticsReport.php` +- Create: `app/Actions/Analytics/GetAnalyticsBounds.php` +- Create: `app/Actions/Analytics/ReadPublicationAnalytics.php` +- Create: `app/Support/Analytics/PeriodBuckets.php` +- Create: `app/Support/Analytics/MetricComparison.php` +- Test: `tests/Feature/Analytics/WorkspaceAnalyticsReportTest.php` + +**Interfaces:** +- Consumes: all four analytics models. +- Produces: `BuildWorkspaceAnalyticsReport::execute(Workspace $workspace, DateRange $range): array` and `ReadPublicationAnalytics::latestForPostPlatform(PostPlatform $postPlatform): array`. + +- [ ] **Step 1: Write failing query tests covering every dashboard block** + +Create two Instagram accounts and one X account in the same workspace plus a foreign-workspace account. Assert: + +- min/max date bounds from snapshots or publications; +- range validation and equal-length previous range; +- end-date follower total, per-account Line/Bar/Growth, carry-forward provenance; +- Posts Bar totals and daily/weekly/monthly zero-filled buckets at 14/15/90/91-day boundaries; +- exactly five Summary values; +- pooled engagement `sum(numerator) / sum(denominator)`, excluding only invalid denominators; +- deterministic Top 5 ties by publication time then id; +- Performance rows per social account, including two separate Instagram rows; +- historical rows after live account deletion; +- cumulative metrics for posts selected by publication date use their latest + successful observation and are never summed across snapshot dates; +- an imported YouTube upload without authoritative Short metadata is presented + as YouTube Video, not falsely as YouTube Short; +- no excluded platform or foreign-workspace contribution; +- unavailable/null distinct from zero. + +- [ ] **Step 2: Run read-model tests and verify they fail** + +Run: `php artisan test --compact tests/Feature/Analytics/WorkspaceAnalyticsReportTest.php` + +Expected: FAIL because report Actions are absent. + +- [ ] **Step 3: Implement date/bucket value objects and indexed queries** + +Avoid JSON predicates for dashboard aggregation. Select latest publication snapshot through portable subqueries keyed by publication id and maximum collected time/date. Use scalar nullable columns and Eloquent/query builder without `ILIKE`, driver-specific date truncation, or database-specific JSON functions. Generate calendar bucket boundaries in PHP and group fetched aggregate rows into those boundaries. + +The response shape is stable: + +```php +[ + 'bounds' => ['min' => '2025-09-23', 'max' => '2026-09-23'], + 'range' => ['start' => '2026-08-25', 'end' => '2026-09-23'], + 'summary' => [...], + 'followers' => ['total' => 15200, 'accounts' => [...], 'series' => [...]], + 'posts' => ['resolution' => 'weekly', 'accounts' => [...], 'buckets' => [...]], + 'top_posts' => ['reactions' => [...], 'comments' => [...]], + 'performance' => [...], + 'coverage' => [...], +]; +``` + +- [ ] **Step 4: Run query tests and inspect query count** + +Run: `php artisan test --compact tests/Feature/Analytics/WorkspaceAnalyticsReportTest.php` + +Expected: PASS with a fixed query count that does not grow with account/publication count. + +- [ ] **Step 5: Run the same test suite against PostgreSQL and MySQL** + +Run the repository's configured PostgreSQL and MySQL CI/database commands. Expected: identical values and ordering on both engines; JSON assertions use recursive equality. + +- [ ] **Step 6: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Dto/Analytics/DateRange.php app/Actions/Analytics app/Support/Analytics tests/Feature/Analytics/WorkspaceAnalyticsReportTest.php +git commit -m "feat: query workspace analytics reports" +``` + +### Task 13: Replace analytics web, post, REST, and MCP read paths with persisted data + +**Files:** +- Modify: `app/Http/Controllers/App/AnalyticsController.php` +- Modify: `routes/app.php` +- Modify: `app/Http/Controllers/App/PostController.php` +- Modify: `app/Services/Post/PostMetricsFetcher.php` +- Modify: `app/Mcp/Tools/Post/GetPostMetricsTool.php` +- Modify: `app/Http/Controllers/Api/PostController.php` +- Modify: `app/Http/Resources/Api/PostMetricsResource.php` +- Test: `tests/Feature/Analytics/AnalyticsControllerTest.php` +- Test: `tests/Feature/Analytics/PersistedPostMetricsReadTest.php` +- Modify: `tests/Feature/AnalyticsResilienceTest.php` + +**Interfaces:** +- Consumes: Task 12 queries. +- Produces: one workspace analytics Inertia response and one persisted publication-detail contract shared by web/API/MCP. + +- [ ] **Step 1: Write failing no-provider-read tests** + +Seed analytics rows, call `/analytics`, post metrics JSON, REST, and MCP, then assert response values and `Http::assertNothingSent()`. Assert an account id from another workspace cannot affect results. Assert LinkedIn, Telegram, Discord, and Google Business expose no V1 block. + +- [ ] **Step 2: Run controller/read tests and verify they fail** + +Run: `php artisan test --compact tests/Feature/Analytics/AnalyticsControllerTest.php tests/Feature/Analytics/PersistedPostMetricsReadTest.php tests/Feature/AnalyticsResilienceTest.php` + +Expected: FAIL because current controllers call provider services and Redis-backed `PostMetricsFetcher`. + +- [ ] **Step 3: Make `AnalyticsController@index` the only dashboard read endpoint** + +Validate `start`/`end` as dates, clamp them to available bounds, authorize the current workspace, and pass the Task 12 report to `Inertia::render('analytics/Index', ...)`. Remove the per-account `show` route and provider dispatch after all frontend callers are removed. + +- [ ] **Step 4: Convert `PostMetricsFetcher` into a persisted read facade** + +Remove `Cache::remember` and all social-service dependencies. It delegates to `ReadPublicationAnalytics`, returns canonical metric keys/labels/units/freshness/origin, and preserves its web/API/MCP callers until their response types are updated together. + +- [ ] **Step 5: Run all analytics read tests** + +Run: `php artisan test --compact tests/Feature/Analytics/AnalyticsControllerTest.php tests/Feature/Analytics/PersistedPostMetricsReadTest.php tests/Feature/AnalyticsResilienceTest.php tests/Feature/Mcp` + +Expected: PASS and no provider HTTP requests. + +- [ ] **Step 6: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Http/Controllers app/Services/Post/PostMetricsFetcher.php app/Mcp routes/app.php tests/Feature/Analytics tests/Feature/AnalyticsResilienceTest.php tests/Feature/Mcp +git commit -m "feat: read analytics exclusively from database" +``` + +### Task 14: Build the workspace analytics dashboard + +**Files:** +- Replace: `resources/js/pages/analytics/Index.vue` +- Create: `resources/js/components/analytics/workspace/types.ts` +- Create: `resources/js/components/analytics/workspace/AnalyticsSection.vue` +- Create: `resources/js/components/analytics/workspace/SummaryCards.vue` +- Create: `resources/js/components/analytics/workspace/FollowersChart.vue` +- Create: `resources/js/components/analytics/workspace/PostsChart.vue` +- Create: `resources/js/components/analytics/workspace/TopPosts.vue` +- Create: `resources/js/components/analytics/workspace/PerformanceTable.vue` +- Create: `resources/js/components/analytics/workspace/ImportCoverage.vue` +- Create: `resources/js/components/analytics/workspace/AccountIdentity.vue` +- Create: `resources/js/components/analytics/workspace/charts/LineChart.vue` +- Create: `resources/js/components/analytics/workspace/charts/HorizontalBarChart.vue` +- Create: `resources/js/components/analytics/workspace/charts/StackedBarChart.vue` +- Modify: `lang/en/analytics.php` +- Modify: every locale counterpart required by localization parity +- Test: `tests/Browser/WorkspaceAnalyticsTest.php` + +**Interfaces:** +- Consumes: Task 13 Inertia report shape. +- Produces: responsive workspace dashboard matching the reference behavior without provider calls. + +- [ ] **Step 1: Write the failing browser test** + +The test seeds two Instagram accounts and one X account, visits `/analytics`, asserts exactly five Summary cards, separate account labels, Line/Bar/Growth switching, Posts Bar/Stacked Bar switching, Top 5 Reactions/Comments switching, Performance rows, range navigation, origin labels, coverage state, empty state, and no JavaScript errors/console logs. + +```php +$page = visit(route('app.analytics')); +$page->assertSee('Total Followers') + ->assertSee('@first · Instagram') + ->assertSee('@second · Instagram') + ->click('Growth') + ->assertSee('-20') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); +``` + +- [ ] **Step 2: Run the browser test and verify it fails** + +Run: `php artisan test --compact tests/Browser/WorkspaceAnalyticsTest.php` + +Expected: FAIL because the workspace components are absent. + +- [ ] **Step 3: Implement typed dashboard composition and date filter** + +Use a single root element, existing `DateRangePicker`, and an Inertia GET visit preserving state/scroll. Set picker min/max from report bounds and disable it in the no-data/import-pending state. All chart-mode toggles are client-side because the response contains every required series. + +- [ ] **Step 4: Implement dependency-free visualizations** + +`LineChart.vue` computes SVG points from daily values and leaves gaps where no observation exists. `HorizontalBarChart.vue` supports positive/negative Growth around a zero axis. `StackedBarChart.vue` renders zero-filled daily/weekly/monthly buckets. Use stable account colors based on account order/id, platform icons, semantic buttons, keyboard focus, tooltips for precision/freshness, and horizontal scrolling on narrow screens. + +- [ ] **Step 5: Implement reporting blocks and translations** + +Summary contains only Posts, Total Followers, Reactions, Comments, and +Engagement Rate. Top 5 cards show destination origin and only valid actions. +Performance sorting is local over the returned rows. Unsupported renders an em +dash, never `0`; measured zero renders `0`. Summary, Top 5, and Performance +tooltips disclose that reactions/comments are the latest cumulative values for +posts published in the selected period, not events that occurred inside it. + +- [ ] **Step 6: Run browser and frontend checks** + +```bash +php artisan test --compact tests/Browser/WorkspaceAnalyticsTest.php +npm run lint +npx vue-tsc --noEmit +npm run build +``` + +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +git add resources/js/pages/analytics resources/js/components/analytics/workspace lang tests/Browser/WorkspaceAnalyticsTest.php +git commit -m "feat: add workspace analytics dashboard" +``` + +### Task 15: Replace individual-post analytics UI and add external publication detail + +**Files:** +- Modify: `resources/js/components/posts/PostPlatformMetrics.vue` +- Modify: `resources/js/pages/posts/Show.vue` +- Create: `resources/js/components/analytics/workspace/PublicationMetrics.vue` +- Create: `resources/js/pages/analytics/Publications/Show.vue` +- Create: `app/Http/Controllers/App/AnalyticsPublicationController.php` +- Modify: `routes/app.php` +- Modify: `lang/en/posts.php` +- Modify: locale counterparts required by parity +- Test: `tests/Feature/Analytics/AnalyticsPublicationControllerTest.php` +- Test: `tests/Browser/PublicationAnalyticsTest.php` + +**Interfaces:** +- Consumes: Task 13 persisted detail contract. +- Produces: rich persisted metrics for TryPost destinations and a read-only route for imported external publications. + +- [ ] **Step 1: Write failing authorization and browser tests** + +Assert workspace ownership, imported rows have no edit/retry/delete action, `Published via TryPost` versus `Published on Instagram`, content-specific metric groups, canonical display units, last-collected/stale/estimated labels, excluded-platform absence, and no provider request. + +- [ ] **Step 2: Run tests and verify they fail** + +Run: `php artisan test --compact tests/Feature/Analytics/AnalyticsPublicationControllerTest.php tests/Browser/PublicationAnalyticsTest.php` + +Expected: FAIL because the page and persisted UI contract are absent. + +- [ ] **Step 3: Implement controller and shared presentation component** + +Authorize through the publication's immutable workspace id. The controller passes publication identity, origin, public URL, preview, account snapshot, and latest metric groups. `PublicationMetrics.vue` renders common engagement, exposure, and video-retention sections and is reused by `PostPlatformMetrics.vue`. + +- [ ] **Step 4: Remove request-time fetching from the post component** + +Pass persisted metrics as page props or load them from the local-only JSON endpoint. Do not keep `onMounted` provider semantics, Redis loading language, or swallowed provider errors. Excluded destinations do not render the block. + +- [ ] **Step 5: Run tests and frontend checks** + +```bash +php artisan test --compact tests/Feature/Analytics/AnalyticsPublicationControllerTest.php tests/Browser/PublicationAnalyticsTest.php tests/Browser/PostShowContentTypeTest.php +npm run lint +npx vue-tsc --noEmit +``` + +Expected: PASS. + +- [ ] **Step 6: Format and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Http/Controllers/App/AnalyticsPublicationController.php routes/app.php resources/js/components/posts resources/js/components/analytics/workspace/PublicationMetrics.vue resources/js/pages/analytics/Publications lang tests/Feature/Analytics tests/Browser +git commit -m "feat: persist individual publication analytics" +``` + +### Task 16: Verify rollout, observability, portability, and remove obsolete analytics code + +**Files:** +- Modify: `config/horizon.php` +- Delete: `resources/js/components/analytics/AnalyticsAccountSelector.vue` +- Delete: `resources/js/components/analytics/FacebookAnalytics.vue` +- Delete: `resources/js/components/analytics/GoogleBusinessAnalytics.vue` +- Delete: `resources/js/components/analytics/InstagramAnalytics.vue` +- Delete: `resources/js/components/analytics/LinkedInPageAnalytics.vue` +- Delete: `resources/js/components/analytics/MetricsGrid.vue` +- Delete: `resources/js/components/analytics/PinterestAnalytics.vue` +- Delete: `resources/js/components/analytics/TelegramAnalytics.vue` +- Delete: `resources/js/components/analytics/ThreadsAnalytics.vue` +- Delete: `resources/js/components/analytics/TikTokAnalytics.vue` +- Delete: `resources/js/components/analytics/XAnalytics.vue` +- Delete: `resources/js/components/analytics/YouTubeAnalytics.vue` +- Delete: `resources/js/components/analytics/types.ts` +- Modify: `docs/superpowers/specs/2026-09-23-workspace-follower-analytics-design.md` +- Test: `tests/Feature/Analytics/AnalyticsObservabilityTest.php` +- Test: `tests/Feature/LocalizationParityTest.php` + +**Interfaces:** +- Consumes: the completed feature. +- Produces: deployable queue configuration, truthful operational logs, clean code, and verified cross-engine behavior. + +- [x] **Step 1: Write failing observability and queue configuration tests** + +Assert every log context contains workspace id, social-account key, platform, collector, date/cursor, attempt, and sanitized category but excludes access/refresh tokens and raw sensitive responses. Assert analytics jobs use the `analytics` queue and Horizon supervises it. + +- [x] **Step 2: Run observability tests and verify they fail** + +Run: `php artisan test --compact tests/Feature/Analytics/AnalyticsObservabilityTest.php` + +Expected: FAIL until queue/log configuration is complete. + +- [x] **Step 3: Configure the queue and clean obsolete read paths** + +Add the analytics queue to existing Horizon supervisors without changing unrelated queue balancing. Remove old account selector/per-network dashboard components only after `rg` proves no imports. Keep low-level social analytics calls that normalized collectors share; remove translated request-time wrappers only when no publisher, test, API, or MCP path references them. + +- [x] **Step 4: Run targeted and full verification** + +```bash +vendor/bin/pint --dirty --format agent +php artisan test --compact tests/Feature/Analytics tests/Feature/Services/Social tests/Feature/Observers tests/Feature/Mcp tests/Browser/WorkspaceAnalyticsTest.php tests/Browser/PublicationAnalyticsTest.php tests/Feature/LocalizationParityTest.php +npm run lint +npx vue-tsc --noEmit +npm run build +php artisan test --compact +``` + +Expected: all pass. + +- [x] **Step 5: Verify PostgreSQL and MySQL** + +Run the full database-dependent analytics suite on both supported engines. Confirm migrations roll up/down, all four unique keys enforce the same identities, nullable booleans/JSON are asserted portably, and aggregate ordering is deterministic. + +Local verification: PostgreSQL Feature 3,853 passed (14,726 assertions; 1 skipped), Unit 1,335 passed (3,618 assertions), and browser 74 passed (301 assertions). On an isolated temporary MySQL database, the analytics and post-consumer suite passed 225 tests (939 assertions); the four analytics migrations rolled back and reapplied successfully. A later focused MySQL run for the authorization/fixture safety fix passed 34 tests (148 assertions), then its temporary database was removed. Frontend lint, typecheck, and build passed. These checks do not validate production API permissions or quota. + +After the X timeline-cap and account-reactivation fixes, the complete default +PostgreSQL test command (`php artisan test --compact`) was rerun on 2026-09-23: +5,196 passed, 1 skipped, 18,389 assertions. This is fresh regression evidence +for the committed branch; it is not a new MySQL, browser, provider-permission, +or production-canary result. + +- [ ] **Step 6: Perform controlled capability and rollout checks** + +Local canary evidence (2026-09-23): the workspace from the reported +`/analytics` request (`01a0caa6-1121-732e-9197-4ab7bbc8b5d9`) has one +eligible Threads account. Its queued history backfill is `complete`, reached +the 365-day target (oldest publication 2025-09-23), and stored 50 external +publications with 50 measured metric snapshots plus one actual follower +snapshot. Running `analytics:backfill-existing --workspace=` +reported `historical_identity_unrecoverable=0`; afterward the analytics queue +was empty and the publication count was unchanged, confirming this local +rerun did not duplicate the catalog. A failed-page bootstrap now preserves the +last committed cursor, verified by 13 backfill tests on both PostgreSQL and +MySQL. This local canary does not prove production application permissions, +quotas, other platform adapters, or a safe global rollout. + +Post-canary safety audit: an analytics-only `401`/`403` no longer calls +`markAsTokenExpired()` on an otherwise connected account. It records the +analytics failure without a false follower value, while the separately +scheduled connection verifier owns publishing-health transitions. The focused +tests cover both rejection categories and isolate account-created jobs in +schema/backfill fixtures; the full PostgreSQL Feature and browser suites pass. + +Pinterest pagination audit: the official [Pins API contract](https://github.com/pinterest/pinterest-python-generated-api-client/blob/main/docs/PinsApi.md) +documents bookmark pagination but does not guarantee chronological ordering; +the [pagination reference](https://developers.pinterest.com/docs/reference/pagination/) +allows 250 items per page. The collector now skips out-of-range Pins while +following every bookmark at that page size, and its +sync page explicitly disables date-boundary early completion. Regression tests +cover an old Pin before a newer Pin and a Pin exactly on the cutoff with a +remaining bookmark. The 137-test PostgreSQL analytics suite passes; live +Pinterest permissions, volume, and bookmark behavior remain rollout checks. + +Instagram/YouTube local canary (2026-09-23): both accounts in workspace +`01a0caa6-1121-732e-9197-4ab7bbc8b5d9` completed the publication backfill. +Instagram has 50 publications, 50 metric snapshots, and one follower snapshot. +YouTube imported 38 publications, but its two newest videos initially lacked +metric snapshots because the Analytics API returned no processed rows. The +official [YouTube data model](https://developers.google.com/youtube/analytics/data_model) +documents a typical 48–72-hour delay and recommends Data API `videos.list` +for current counts. A fallback now records views, likes, and comments from +that endpoint when the Analytics report is empty; two isolated queued canary +jobs produced the missing snapshots, bringing YouTube to 38/38. The isolated +queue emptied. Re-running `analytics:backfill-existing` only for this workspace +reported `historical_identity_unrecoverable=0` and left publication/snapshot +counts unchanged. These are local app credentials and do not prove other +platforms or production quotas. + +X timeline-cap safeguard (2026-09-23): regression tests now cover the +3,200-post cap, true exhaustion below that cap, failed-cursor resume, +stale-worker fencing, and count reset on invalid cursor or terminal restart. +No live X request was made; billed-read cost remains a release gate. + +Account reactivation audit (2026-09-23): re-enabling an inactive connected +account now dispatches its initial follower and publication bootstrap jobs. +Previously the observer only reacted to a status change, so a paused account +could stay without a backfill until an operator ran the rollout command. +Disconnected or excluded accounts still do not start analytics collection. + +Before dispatching the production rollout: + +1. confirm the production TikTok app has `video.list` and `user.info.stats`; +2. connect/test one Instagram-direct and one Instagram-via-Facebook account; +3. verify Facebook Page posts, videos/Reels, and follower fields with the current Page token; +4. confirm Threads follower insights and owned-post pagination with a production-approved token; +5. measure X follower, owned-post, and metric read cost on one bounded 365-day account before widening rollout; +6. verify Pinterest owned-Pin bookmarks, lifetime/range analytics, and the production app's read scopes; +7. verify the YouTube uploads playlist, batched video details, channel statistics, and Analytics API scopes; +8. confirm Bluesky repository pagination plus public count hydration against a large account; +9. reconnect a Mastodon test account with `read:statuses` and confirm private-history behavior on two different instances; +10. verify follower collection once for every included platform and both Instagram login variants; +11. export the selected canary workspace UUID as `ANALYTICS_CANARY_WORKSPACE_ID`, then run `php artisan analytics:backfill-existing --workspace="$ANALYTICS_CANARY_WORKSPACE_ID"`; +12. verify coverage, orphan-skip, rate-limit, and retry states, then run the command without the workspace filter. + +- [ ] **Step 7: Update spec status and commit** + +Mark implemented gates with the actual provider limitations observed; do not weaken documented coverage silently. + +```bash +git add config .env.example app resources/js docs/superpowers/specs tests +git commit -m "chore: finalize workspace analytics rollout" +``` + +## Self-Review Results + +- **Spec coverage:** Every V1 surface, included/excluded platform, follower fallback, native backfill, reconciliation rule, metric catalog, date range, Summary, Top 5, Performance, individual detail, REST/MCP read path, and LinkedIn V2 boundary maps to Tasks 1–16. +- **Schema audit:** The four-table design is retained as the minimum safe split. + Publication snapshots no longer duplicate tenant ownership, and sync state is + reduced to two live-account cursor workflows rather than becoming a second + job/fact ledger. +- **Concurrency audit:** Unique constraints arbitrate missing-row races, + same-day metric families merge under a row lock, and paginated jobs advance + only a checkpoint version they actually fetched. +- **Lifecycle audit:** Historical facts survive account/post deletion, sync + checkpoints do not, reconnects reuse identity through provider ids, and + pre-rollout orphan destinations are skipped and disclosed rather than guessed. +- **Placeholder scan:** The plan contains no forbidden placeholder markers, no unnamed error handling, and no task that delegates unspecified work. Provider mappings and final manual capability gates are explicit. +- **Type consistency:** The four model names, DTO constructors, collector signatures, origin values, sync states, and query method names are introduced once and reused consistently. +- **Review focus:** Each of the five highest-risk inputs is pinned to an explicit test in Tasks 1/2, 3/10, 5, 9, or 12. +- **Scope decomposition:** Backend persistence, account collection, publication discovery, metric collection, read model, dashboard, and detail UI are independent review gates but remain in one plan and one branch because their contracts form one source-of-truth migration. diff --git a/docs/superpowers/specs/2026-09-23-workspace-follower-analytics-design.md b/docs/superpowers/specs/2026-09-23-workspace-follower-analytics-design.md new file mode 100644 index 000000000..e3fb1ab97 --- /dev/null +++ b/docs/superpowers/specs/2026-09-23-workspace-follower-analytics-design.md @@ -0,0 +1,1440 @@ +# Workspace follower and post analytics — design + +**Status:** V1 implemented on `feat/workspace-analytics-backfill`; production provider validation and a controlled canary backfill remain release gates. + +The four analytics migrations have been applied to the local PostgreSQL app +database. The workspace dashboard, queued daily collection and native-history +import, persisted post metrics, and individual-publication detail are in the +branch. On a separate temporary MySQL database, 225 analytics/post-consumer +tests passed (939 assertions), and all four migrations passed rollback and +reapplication; that temporary database was removed. Production app permissions +and provider quotas remain unverified: run the controlled capability checks and +canary rollout below before dispatching a global backfill. LinkedIn remains V2. +The local PostgreSQL Feature suite passed 3,853 tests, Unit passed 1,335, and +the full browser suite passed 74 after isolating asynchronous analytics jobs in +unrelated synchronous-queue test fixtures. + +A local Threads workspace canary subsequently completed the 365-day publication +backfill with 50 external publications, 50 measured metric snapshots, and an +actual follower snapshot. The workspace-filtered rollout command reported no +unrecoverable orphan identities and did not duplicate records on rerun. This +is local evidence only; production scopes, quotas, other networks, and global +rollout remain unverified. A failed paginated backfill now resumes from its +last committed cursor, with focused PostgreSQL and MySQL tests. + +## Objective + +Replace the request-time, per-social-account analytics experience with an +initial workspace-level analytics view covering follower history, posts +successfully published through TryPost, and native posts imported from connected +social accounts. + +After the daily collection pipeline begins producing local snapshots, the page +must answer seven questions without querying social APIs at request time: + +1. How many followers did this workspace have at the end of the selected + period? +2. How did each connected social account's follower count change over that + period? +3. Which accounts gained or lost followers? +4. How many posts did each social account publish during the selected period, + whether through TryPost or natively, and how was that volume distributed over + time? +5. How did publication volume, reactions, comments, and engagement compare + with the immediately preceding equivalent period? +6. Which destination publications and social accounts performed best? +7. Which detailed metrics, including video-retention metrics where available, + explain the performance of an individual published destination? + +Success means `/analytics` renders without making social API calls, daily +follower collection is resilient to transient failures and rate limits, one +broken platform cannot block another account's data, and post volume is derived +from a local unified publication catalog. Post history and performance metrics +are imported or collected ahead of page requests and retained locally. + +## Scope boundary + +Analytics are always scoped to the **current workspace**. There is no +cross-workspace or user-global total. + +The workspace is the tenancy and aggregation boundary. A social account is a +dimension inside that workspace so two accounts on the same network remain +separate chart series. + +Every query and collection write must validate the relationship between the +workspace and social account. The design must continue to support multiple +accounts of the same network. + +## First-version scope + +### Included platforms + +| Platform | Value represented | Precision / caveat | +| --- | --- | --- | +| TikTok | Profile followers | Exact value exposed by the user stats API | +| Instagram | Professional-account followers | Includes direct Instagram login and Instagram through Facebook | +| Facebook | Page followers | Page follower total, not daily follows gained | +| Threads | Profile followers | Account insight | +| X | Profile followers | Public user metric; subject to the application's X access and billing limits | +| Pinterest | Account followers | Account `follower_count` | +| YouTube | Channel subscribers | YouTube may round the public subscriber count for larger channels | +| Bluesky | Profile followers | Profile `followersCount` | +| Mastodon | Account followers | Account `followers_count` from the connected instance | + +The value is labelled using the platform's native meaning where needed in +tooltips, but all values participate in the workspace's top-level follower +total. + +### Explicit v1 platform exclusions + +The following platforms are excluded from every analytics surface in v1: + +- LinkedIn personal profile +- LinkedIn Page +- Telegram +- Discord +- Google Business Profile + +They receive no follower or post-performance collection jobs, appear in no +follower or Posts chart, contribute to no Summary, Top 5, or Performance value, +and expose no individual-post analytics block in this delivery. Their successful +publication records remain intact but are filtered out of `/analytics`. +Publishing, comments/community features, and account connection behavior remain +unchanged. + +LinkedIn personal follower analytics requires `r_member_profileAnalytics`, +which is provisioned through the vetted Community Management API product. That +product must initially be the only product on a separate LinkedIn developer +application. LinkedIn post metrics are also deferred so the network enters the +new analytics architecture as one coherent v2 rather than partially in v1. + +### Planned v2: LinkedIn + +Analytics for both LinkedIn identity types are planned for v2: + +- LinkedIn personal profile follower count; +- LinkedIn Page follower count; +- database-backed post metrics for personal profiles and Pages; +- inclusion in Posts, Summary, Top 5, Performance, and individual-post detail. + +The v2 keeps the network consistent by introducing both identity types +together. LinkedIn Page data is already technically accessible through the +current application scopes, but personal-profile data remains gated by +Community Management API approval and `r_member_profileAnalytics`. + +Before v2 implementation, TryPost must: + +1. create a separate LinkedIn developer application with no other provisioned + products; +2. request and receive Community Management API access; +3. confirm the production credential arrangement with LinkedIn after approval; +4. add the newly provisioned analytics scope to the appropriate OAuth flow; +5. require affected LinkedIn accounts to reconnect so their tokens contain the + approved scope; +6. verify the current LinkedIn follower, post, video, and engagement metric + endpoints, API version, scopes, and data-retention requirements; +7. create a separate v2 implementation plan for collection, persistence, and + read-path inclusion. + +If Community Management API access is not approved, LinkedIn cannot enter v2. +Shipping LinkedIn Page alone would then require a new explicit product decision +rather than happening implicitly. + +Telegram, Discord, and Google Business Profile have no planned analytics v2 in +this design. Adding any of them later requires a new product decision and +official API capability review. + +## User experience + +### Workspace total + +The Total Followers card inside Summary sums one daily follower value per +included social account for the selected range's end date. + +- An account contributes at most once. +- Two accounts on the same network both contribute. +- An account with no value for the end date does not silently contribute an + older, unclassified value. +- A carried-forward value created by the daily fallback does contribute. +- An account that was disconnected or deactivated before that date does not + receive a snapshot for the date and therefore does not contribute. + +### Follower chart + +One follower widget presents the same workspace dataset in three modes: + +- **Line:** daily follower count per social account across the selected range. +- **Bar:** follower count per social account on the selected end date. +- **Growth:** net change per social account between its first and last available + values inside the selected range. Positive and negative changes share a zero + axis. + +Series and rows use the social account's platform icon, display name or +username, and stable social-account identity. They are not collapsed by +network. + +The initial display mode is Line. Changing modes is client-side because all +three views derive from the same response dataset. + +### Native post history import + +Connecting an account on a platform included in analytics v1 dispatches a +non-blocking native-history backfill. The product target is every owned post +published during the preceding 365 days. The importer paginates until it reaches +that cutoff, exhausts the provider feed, or encounters a documented provider +limit. It never claims a complete year when the API returned less. + +For Pinterest, `GET /pins` documents bookmark pagination but not chronological +ordering. Its importer uses the documented 250-item page size, filters out +older Pins, and follows bookmarks until the +provider exhausts them; an old Pin cannot prove that later pages lack eligible +Pins. The sync state may stop at the date boundary only when its collector +explicitly guarantees that ordering. + +The account connection succeeds before the backfill finishes. Analytics shows +an import-progress state with the oldest covered publication date, latest +successful sync time, and whether coverage is complete, provider-limited, +partially failed, or still running. Imported results become visible +incrementally; one slow account cannot delay another. + +After the initial backfill, a daily queued discovery job imports newly published +native posts. This is separate from the follower and metric collectors. It uses +a per-account high-water mark plus provider cursor checkpoints, overlaps the +last completed window to tolerate late provider results, and relies on an +idempotent key of workspace + social account + platform + native post id. +Reconnects of the same platform identity resume the catalog rather than creating +a second history. + +Feature rollout also dispatches the same resumable backfill for every already +connected, active account on an included v1 platform. Rollout work is chunked +and rate limited; it does not require users to disconnect and reconnect to seed +their analytics. + +Each imported publication retains, when available: + +- workspace and social-account ownership; +- native post id and platform; +- provider publication timestamp; +- content type; +- permalink; +- caption or textual excerpt; +- preview/thumbnail reference and enough immutable presentation metadata to + render a historical card; +- discovery and last-sync timestamps; +- origin (`trypost` or `external`); +- provider coverage and availability state. + +The publishing enum labels the integration `YouTube Shorts`, but the owned +uploads API returns every channel upload and does not authoritatively classify +all of them as Shorts. Analytics labels imported unknown uploads as `YouTube` +and content type `Video`; only a TryPost destination or provider field that +proves a Short may render `Short`. + +An imported post is an analytics record, not a draft or published `Post` owned +by the TryPost publishing workflow. Importing it must not enable editing, +deletion, retry, repurpose processing, or publishing lifecycle actions. The +physical persistence design may share a catalog with TryPost destinations, but +it must not manufacture `posts` or `post_platforms` rows whose states imply that +TryPost published the content. + +When discovery returns a native id already present on a TryPost destination, +the records reconcile into one analytics publication and `trypost` origin wins. +This prevents one post from being counted twice. The same reconciliation runs +when a delayed publish result gains its provider id after native discovery. + +The current Repurpose source fetchers prove that Instagram and Facebook media +can already be discovered with the connected account. The analytics importer +may extract and share their low-level provider clients and response parsers, but +it does not reuse `PollRepurposeSource`, `RepurposeItem`, media-download rules, +or activation watermarks. Those components fetch only selected video formats, +currently request one page of 25 items, and have different lifecycle semantics. + +For a newly imported publication inside the normal post-metric refresh window, +the importer dispatches the ordinary post-performance job. For an older post in +the 365-day backfill, it dispatches one rate-limited baseline metric collection +and then retains the result without enrolling the post in perpetual daily +refresh. A provider that no longer exposes metrics leaves the post visible with +an explicit unavailable state. + +Post-history backfill does not fabricate follower history. Follower charts begin +at the first real follower observation unless the provider has a separately +documented historical follower endpoint. + +### Posts chart + +A second widget shows the number of analytics publications in the selected +range, combining successful TryPost destinations with imported native posts. It +uses two modes: + +- **Bar:** horizontal total per social account across the entire selected + range. +- **Stacked Bar:** publication count over time, with one colored segment per + social account in each time bucket. + +The initial display mode is Stacked Bar. The time bucket is selected +automatically from the inclusive range length: + +- up to 14 days: one bucket per day; +- 15 through 90 days: one bucket per week; +- more than 90 days: one bucket per calendar month. + +The first and last weekly or monthly buckets may be partial when the selected +range begins or ends inside that period. Empty buckets are returned with zero +values so the time axis remains continuous. + +One analytics publication counts as one post for its social account. For +example, one TryPost post successfully delivered to Instagram and X contributes +one count to each account, while one natively published Instagram post adds one +Instagram count. The metric is based on the reconciled destination/native +publication, not the parent TryPost post, so a partially successful +multi-network post counts only its successful destinations. + +The Posts widget includes only the platforms included in analytics v1. It +excludes LinkedIn personal profiles, LinkedIn Pages, Telegram, Discord, and +Google Business Profile even when their TryPost destination publication +succeeded. For included platforms, it counts posts published from any TryPost +entry point, such as the app, API, MCP, or repurpose flows, plus native posts +discovered through the connected account. It excludes drafts, scheduled posts +that have not yet published, and failed or rejected destinations. + +A retry that eventually succeeds counts once because the destination record is +counted once. Historical publications remain facts even if an account is later +deactivated or disconnected. Account snapshot metadata stored with the +destination is used for historical presentation when the live social-account +row is no longer available. + +### Summary + +The page includes one workspace-level Summary block with exactly five cards: + +- **Posts:** reconciled TryPost and imported native analytics publications whose + provider publication timestamp falls inside the selected range. +- **Total Followers:** the follower total at the selected range's end date, + using the same eligibility rules as the follower widget. +- **Reactions:** the sum of the latest stored reactions for eligible analytics + publications inside the selected range. +- **Comments:** the sum of the latest stored comments for eligible analytics + publications inside the selected range. +- **Engagement Rate:** pooled engagement divided by pooled exposure for the + eligible analytics publications inside the selected range. + +Cross-network labels are normalized for comparison. Reactions include native +likes, favorites, and reactions. Comments include native comments and replies +when the platform exposes replies as its comment-equivalent metric. The +underlying native name remains available in the post detail and tooltip. + +Engagement follows the Buffer-style model approved for this design. Each +platform collector normalizes the interactions that its API treats as +engagement, such as reactions, comments, reposts/shares, saves, and clicks when +available. Exposure uses the platform-appropriate impressions, reach, or views +denominator. The workspace rate is calculated from the pooled numerator and +pooled denominator, rather than averaging post percentages, so a low-exposure +post does not weigh the same as a high-exposure post. + +A destination without a supported or valid exposure denominator is excluded +from Engagement Rate only. Its supported reactions and comments still +contribute to those cards. Unsupported metrics render as unavailable and are +never converted to zero. + +Provider post metrics are cumulative. Historical range reports therefore +answer “how have posts published in this period performed as of their latest +collection,” not “how many reactions happened during this period.” Summary, +Top 5, Performance, and comparison tooltips state this explicitly; TryPost does +not infer a daily reaction timeline that providers did not return. + +### Period comparison + +Summary and Performance compare the selected inclusive range with the +immediately preceding range of equal length. For example, a 30-day selection +compares against the preceding 30 days. The comparison period is calculated +automatically and is not a second user-selectable range. + +- Posts, Reactions, and Comments show percentage change. +- Engagement Rate shows the relative percentage change between the two pooled + rates. +- Total Followers shows the absolute follower change between the two period-end + totals, matching the reference design. +- When the previous value is zero or unavailable, the UI shows a neutral + unavailable/new-data state instead of infinity or a fabricated percentage. +- Partial historical coverage is disclosed in the tooltip and is not presented + as a complete comparison. + +### Top 5 Posts + +The page includes one Top 5 Posts block with a two-option toggle: + +- **Reactions** is the initial ranking; +- **Comments** ranks the same eligible dataset by normalized comments. + +The ranking unit is the reconciled analytics publication, not the parent post. +A parent sent to multiple social accounts may therefore appear more than once +when more than one destination qualifies. Imported native posts participate as +their own publication. Only publications inside the selected range participate. + +Each card shows rank, normalized metric value, platform/account identity, +publication date, content type, excerpt, thumbnail when available, publication +origin, and actions to open the TryPost post or its public social URL when +supported. An imported native post has no edit action or fake TryPost post link. +Ties are resolved by newest provider publication timestamp and then by stable +analytics-publication id so the order does not jump between requests. + +A destination whose selected ranking metric is unsupported is excluded from +that ranking. Fewer than five cards are shown when fewer than five eligible +destinations have a real value. An empty state replaces the list when none do. + +### Performance + +The page includes one Performance table with one row per social account that has +an eligible TryPost or imported native publication in the selected range. +Multiple accounts on the same network remain separate rows. + +The fixed first-version columns are: + +- Channel; +- Posts; +- Reactions; +- Comments; +- Engagement Rate. + +Posts use the local reconciled analytics-publication count. The other columns +aggregate the latest stored post-performance observations using the same +normalization and pooled-rate rules as Summary. Each supported numeric column +can be sorted, and its current value includes the equivalent-period comparison +when a valid comparison exists. + +When a network or content type does not expose a metric, the cell shows an +unavailable marker rather than zero. Historical account snapshot metadata keeps +rows presentable after an account is disconnected or deleted. + +These are exactly the three additional reporting blocks in v1: Summary, Top 5 +Posts, and Performance. More cards, ranking modes, or configurable Performance +columns require a later product decision. + +### Individual post analytics + +For platforms included in analytics v1, the existing analytics area inside each +published post is part of this same delivery. It must stop fetching provider +metrics during the page request and must stop treating the five-minute Redis +entry as the metric source. Destinations on excluded platforms show no analytics +block. + +Imported native posts open a read-only analytics detail using the same metric +components and observation contract. Every detail view displays an origin label: +`Published via TryPost` for a matched TryPost destination, or `Published on +` for an imported native publication. Origin is stored data, not +inferred from the presence of a local caption or URL. + +The post-performance pipeline collects through queued jobs and persists through +one observation writer. The individual post page, REST API, MCP, Summary, Top 5 +Posts, and Performance all read the same latest persisted observation for each +reconciled analytics publication. Redis is not a source of truth for post +analytics; a +database-query cache may be added later only if profiling proves it useful. + +The individual post page is richer than the cross-network reporting blocks. It +shows every persisted metric supported by that platform and content type, +grouped into common engagement, exposure, and video-retention sections. It also +shows when the metrics were last collected and whether the value is actual, +estimated, stale after a failed refresh, experimental, or unsupported. + +The response contract uses stable metric keys and explicit units. Translated +labels are presentation only and are never stored as metric identity. An +unsupported metric is omitted or marked unavailable; an API error must not +replace the most recent successful value with zero. + +### Buffer per-post reference audit + +Buffer's Sent-post and Insights documentation provides the following UX and +normalization reference for individual posts. It is a discovery catalog, not +proof that TryPost's credentials, scopes, account type, or current API version +can retrieve every value. Each collector still requires verification against +the network's official API documentation before implementation. + +| Channel | Per-post metrics exposed or named by Buffer | +| --- | --- | +| Instagram Professional | Reactions/likes, comments, reposts/shares, views or impressions, reach, saves, follows, and engagement rate; availability varies between Feed, Reel, and Story | +| Facebook Page | Reactions, comments, shares/reposts, clicks, reach, views, impressions where still returned, and engagement rate; Group analytics are excluded | +| X/Twitter | Reactions/likes, replies/comments, reposts, quotes, clicks, impressions on the Sent surface, and a Buffer-derived engagement rate | +| LinkedIn Page | Reactions, comments, reposts/shares, impressions, engagement rate, and for video: views, total watch time in minutes, and unique viewers | +| LinkedIn personal profile | Reactions, comments, reach, impressions, video views, and engagement rate; reliable repost counts are not available | +| Pinterest business | Reactions, comments, saves, clicks, impressions, views, and engagement rate where Buffer has a valid exposure value | +| Mastodon | Favorites/reactions, replies/comments, and reblogs/reposts | +| TikTok | Reactions/likes, comments, shares/reposts, views, reach, and engagement rate | +| YouTube | Reactions/likes, comments, video views, shares in aggregate reporting, and a derived engagement rate where Buffer can calculate one | +| Threads | Reactions/likes, comments/replies, reposts, quotes, views, and engagement rate | +| Bluesky | Reactions/likes, replies/comments, reposts, quotes, and a derived engagement rate | + +Buffer does not provide this per-post reference for Telegram, Discord, or +Google Business Profile, and it does not expose post analytics for Instagram +Personal accounts or Facebook Groups. Together with the product decision for +this release, that absence keeps Telegram, Discord, and Google Business Profile +outside analytics v1. Existing locally available reactions, replies, or account +counts for those networks are not promoted into the new analytics surfaces. + +The Buffer product surfaces are not internally identical. Sent posts, the new +Insights product, and the retiring Analyze product can expose different metrics +and historical windows. For example, Buffer documents X impressions in Sent +posts while also saying its Insights visibility view has no X impressions or +views. TryPost records the provider metric identity, source, time basis, and +formula so a value is never promoted merely because another Buffer surface +lists it. + +Buffer's normalization vocabulary is useful and is adopted for cross-network +presentation only: + +- `Reactions` covers native likes, favorites, and reactions; +- `Comments` covers comments and reply-equivalents; +- `Reposts` covers retweets, reblogs, reshares, and reposts; +- native names and raw metric identities remain visible in post detail; +- engagement rate must disclose whether it is provider-returned or derived, + plus its interaction numerator and exposure denominator. + +Compared with the existing request-time TryPost collectors, the audit produces +the following implementation inventory. A Buffer-only metric is a candidate to +verify, not permission to invent or request an undocumented field. + +| Channel | Existing TryPost per-post collector | Candidate gap to verify | +| --- | --- | --- | +| Facebook | Feed: impressions, reach, likes, clicks; Story: impressions, reach, interactions, reactions, replies, shares; Reel/video: plays, reactions, interactions | Feed comments/shares and richer Reel actions exposed by current Meta APIs | +| Instagram | Reach, views, likes, comments, shares, saves, replies, or total interactions depending on content type | The expanded Feed/Reel/Story catalog below | +| X/Twitter | Impressions, likes, retweets, replies, quotes, and bookmarks | Click metrics and their access level | +| LinkedIn Page | Impressions, clicks, likes, comments, and shares | Deferred to v2: provider engagement rate plus video views, watch time, and unique viewers | +| LinkedIn personal profile | Likes and comments | Deferred to v2: impressions, reach, reliable video views, and derived engagement inputs under newly approved scopes | +| Pinterest | Impressions, saves, Pin clicks, outbound clicks, and video views | Lifetime reactions/comments, rates, audience values, and video-retention metrics described below | +| Mastodon | Favorites, replies, and reblogs | No Buffer-identified basic metric gap | +| TikTok | Views, likes, comments, and shares | Reach is a Buffer candidate but is unavailable in TryPost's currently approved Display API fields | +| YouTube | Views, total watch time, average view duration, likes, comments, and shares | Engaged views, average percentage viewed, and subscriber change described below | +| Threads | Views, likes, replies, reposts, and quotes | No Buffer-identified basic metric gap | +| Bluesky | Likes, replies, reposts, and quotes | No Buffer-identified basic metric gap | + +LinkedIn rows above are v2 research only. They are not part of the v1 collector +or persistence scope. Telegram, Discord, and Google Business Profile are omitted +from the implementation inventory because they are excluded from analytics v1 +and have no planned analytics v2 in this design. + +### Content-specific metric catalog + +The initial content-type analysis establishes the following catalog. It is the +minimum that the platform collectors should request and persist when supported +by the connected account, login type, API version, and media type. + +| Content type | Metrics for the individual post page | Current TryPost gap | +| --- | --- | --- | +| Instagram feed | Views, reach, likes/reactions, comments, shares, saves, reposts, total interactions, follows, profile visits, and profile activity | The current collector omits views, reposts, follows, profile visits, and profile activity | +| Instagram Reel | Views, reach, likes/reactions, comments, shares, saves, reposts, total interactions, total watch time, average watch time, and skip rate when returned | The current collector already has views/reach/basic engagement but omits interactions, reposts, watch-time metrics, and skip rate | +| Instagram Story | Views, reach, replies, shares, reposts, follows, profile visits/activity, link clicks, and navigation breakdown | The current collector only requests views, reach, and replies | +| YouTube video or Short | Video views, engaged views, watch time, average view duration, average percentage viewed, likes/reactions, comments, shares, subscribers gained, and subscribers lost | The current collector already has views, watch time, average duration, likes, comments, and shares, but omits engaged views, average percentage viewed, and subscriber change | +| TikTok video | Views, likes, comments, and shares | The current Display API collector already exposes the complete performance set available to this integration; video duration is metadata, not watch time | +| Pinterest image Pin | Impressions, saves, comments, reactions, engagements, engagement rate, save rate, Pin clicks/rate, outbound clicks/rate, profile visits, follows, total audience, and engaged audience | The current collector has impressions, saves, Pin clicks, and outbound clicks, but omits the remaining native and lifetime metrics | +| Pinterest video Pin | Every applicable image-Pin metric plus video views, average video play time, 10-second plays, plays to 95%, and total play time | The current collector only adds basic video views and omits the richer video-retention metrics | + +Instagram Reel total watch time is displayed in minutes and average watch time +in seconds, matching the reference UI, while persistence stores both as integer +milliseconds to avoid rounding drift between providers and displays. Metrics +that Meta marks estimated or in +development, currently including Reel reach, watch time, views, total +interactions, and skip rate as applicable, preserve that precision/stability +metadata for tooltips. + +Meta documents that Instagram insight values can lag by up to 48 hours. A +successful response with an absent or not-yet-populated metric is therefore not +converted to measured zero. The read model keeps the last successful value and +exposes its collection time so the UI can distinguish fresh, delayed, and stale +data. Provider retention does not control TryPost retention: once collected, +the observation remains stored under TryPost's permanent-history policy. + +Instagram Reel engagement rate uses the normalized interactions divided by +reach when both are available. This matches the reference behavior and avoids +using repeated views as though they were unique people. + +The Meta collector must parse both `values[].value` and `total_value.value`, and +must preserve requested breakdowns such as Story navigation actions. It splits +incompatible or experimental metric families into separate provider requests: +one rejected metric must not blank every otherwise supported metric for the +post. + +Instagram cross-posted and Facebook-only view metrics are conditional: they are +stored and displayed only when the Reel was actually shared or recommended to +Facebook and the API returns them. They do not replace Instagram views. + +The approved TikTok Display API does not expose total watch time, average watch +time, completion rate, or retention. Those values must remain unavailable +rather than being inferred from view count and video duration. + +YouTube's per-video report already supports the retention metrics needed for +Shorts. The collector expands its current query rather than introducing a +second Shorts-specific API path. + +YouTube Analytics data typically lags by 48–72 hours. When a video report has +headers but no processed row, the collector uses `videos.list?part=statistics` +for current lifetime views, likes, and comments. It stores only fields the Data +API actually returns; missing values are not zero. Later scheduled collections +can add watch time, retention, shares, and subscriber metrics once the Analytics +report is available. + +On the individual YouTube post, the cross-network `Reactions` label maps to the +native `likes` metric, `Comments` maps to `comments`, and `Video Views` maps to +`views`. The YouTube Analytics API does not return a native per-video engagement +rate, so that native field is unavailable rather than fabricated. A normalized +TryPost engagement rate used by Summary or Performance is a separately labelled +derived value with its numerator and `views` denominator preserved; it must not +be represented as a provider-returned YouTube metric. + +Pinterest uses two complementary sources. The Pin Analytics endpoint provides +date-range metrics, including impressions, saves, engagement, clicks, rates, +and video performance. `GET /pins/{pin_id}?pin_metrics=true` provides rolling +and lifetime Pin metrics, including total comments and total reactions. The +collector combines both into one idempotent observation while retaining each +metric's time basis (`range`, `rolling_90_day`, or `lifetime`). A lifetime value +must never be presented as though it occurred entirely inside the selected +dashboard range. + +Pinterest's native engagement rate remains the provider-defined engagements +divided by impressions. Saves, comments, and reactions are displayed as their +own metrics; comments and reactions are not silently added to the provider's +engagement numerator unless Pinterest includes them in the returned native +definition. This keeps the reference labels without changing their meaning. + +Instagram Stories require an exception to the normal 30-day refresh window: +their media insights are generally available for only 24 hours. A queued +collection is scheduled during the Story lifetime, the `story_insights` webhook +is enabled when the integration supports it, and a final collection runs +shortly before expiry. Webhook deliveries and scheduled jobs persist through the +same idempotent observation writer. The normal once-daily sweep alone is +insufficient because it can miss the availability window. Stored Story metrics +remain available after the provider stops serving them. Privacy-threshold or +"not enough viewers" responses mean unavailable, not measured zero and not a +reason to erase a previous observation. + +### Date range + +The existing analytics range date picker remains the shared page filter for the +Summary, follower chart, Posts widget, Top 5 Posts, and Performance. + +- `minDate` is the earliest follower snapshot or reconciled TryPost/native + analytics publication on an included v1 platform available in the workspace. +- `maxDate` is the latest follower snapshot or reconciled TryPost/native + analytics publication on an included v1 platform available in the workspace. +- The picker cannot select a range wholly outside those bounds. +- All chart modes and the total use the same selected range. +- A social account connected after the selected start date begins when its own + data begins; no pre-connection values are invented. +- A widget shows its own empty state when the selected range contains no data + for that metric. +- With neither follower snapshots nor analytics publications, the picker is + disabled and the page shows an analytics-empty/import-pending state. + +Historical data for a disconnected or deactivated account is retained. Its +line ends on the last day for which it was eligible; it remains visible when +the selected range overlaps that history. + +## Collection architecture + +```text +Laravel scheduler (daily, UTC) + -> native-post discovery dispatch-only command + -> one queued incremental discovery job per eligible social account + -> platform-owned-post paginator + -> reconciled analytics publication catalog + -> post-performance jobs for new publications + + -> follower dispatch-only command + -> one queued job per eligible social account + -> platform follower collector + -> normalized follower observation + -> persistence boundary + + -> post-performance dispatch-only command + -> one queued job per eligible reconciled analytics publication + -> platform post-metrics collector or trusted local metric source + -> normalized post-performance observation + -> persistence boundary + + -> Instagram Story lifecycle jobs and `story_insights` webhook + -> same idempotent post-performance observation writer + +End-of-day finalizer + -> identifies eligible accounts without a successful observation + -> carries forward the most recent known value when one exists + +Account connection + -> immediate follower collection + -> resumable native-post backfill through the preceding 365 days +``` + +### Scheduler and dispatcher + +The scheduled command starts at `02:00 UTC`, runs with +`withoutOverlapping()` and `onOneServer()`, reads eligible accounts in bounded +chunks, and only dispatches jobs. It never calls a social API itself. + +An account is eligible when it: + +- belongs to a workspace; +- uses an included platform; +- is active; +- is connected and has the platform metadata required by its collector. + +Each account gets an independent job on a dedicated analytics queue. The job's +logical uniqueness key is follower metric + social account + UTC observation +date. Re-dispatching the same logical job is safe and cannot create a second +daily value. + +Connecting a supported account dispatches an immediate first follower +collection and the resumable native-history backfill so the workspace does not +wait for the next daily sweep. These jobs follow the same isolation, +idempotency, and widely spaced retry principles as their scheduled counterparts. +Backfill jobs run in bounded pages and re-dispatch the next page rather than +holding one worker for the entire year. + +### Collector contract + +Each platform-specific collector has one responsibility: fetch the current +follower-equivalent value for one social account and return a normalized +observation. A collector does not authorize workspace access, aggregate totals, +or know the database schema. + +The normalized result contains, at minimum: + +- metric identity (`followers` at this stage); +- integer value; +- observation date in UTC; +- actual versus carried-forward provenance; +- exact versus approximate precision; +- platform response timestamp when the API provides one. + +This contract is intentionally independent of the physical persistence model +so future metrics can reuse the collection pipeline after their data shapes are +known. + +### Retry policy + +Transient HTTP failures, connection failures, server errors, and rate limits +must retry far apart within the same UTC day. The target attempt windows are: + +- 02:00 +- 06:00 +- 10:00 +- 14:00 +- 18:00 +- 22:00 + +The actual delayed execution may occur later under queue load. When a platform +returns a longer valid retry time, the job respects that time instead of the +four-hour default, provided the attempt still belongs to the observation day. + +Permanent authentication or permission rejection from an analytics endpoint is +not retried six times as a transient error and does not write zero as a +follower value. It also does not by itself mark the entire social account +token-expired: an analytics scope can be missing while publishing still works. +The independent daily connection verifier owns global account-health changes. + +An attempt exits without writing when another attempt has already persisted an +actual observation for the account and date. + +### End-of-day fallback + +After the final attempt window, a finalizer covers eligible accounts that had +no successful API observation that day: + +- If an earlier valid follower value exists, copy it into the current date and + mark it as carried forward / estimated. +- If the account has never produced a valid follower value, no value can be + invented; it remains unavailable until a collection succeeds. +- A disconnected or deactivated account is not eligible for carry-forward. +- A carried-forward value may itself be carried into a later unavailable day, + while retaining provenance that the newest value is not a fresh API + observation. + +This keeps charts and workspace totals continuous during a platform outage +without misclassifying a repeated value as a successful API fetch. + +## Post-performance collection + +The complete supported post metric catalog, including reactions, comments, +exposure, engagement inputs, and video retention, must not trigger social API +calls while `/analytics` or an individual post is rendering. Metrics are +refreshed in queued jobs and stored through the same idempotent persistence +boundary as follower observations. + +The daily dispatcher selects reconciled TryPost/native analytics publications +that have a native post id, use a platform included in analytics v1, have a +connected account with the required access, and remain inside their refresh +window. LinkedIn personal profiles, LinkedIn Pages, Telegram, Discord, and +Google Business Profile are never selected: + +- X destinations: through 20 days after publication; +- every other supported destination: through 30 days after publication. + +Instagram Stories use their separately documented within-24-hours schedule +instead of the 30-day sweep. + +There is no free-versus-paid retention rule in TryPost. All workspaces use the +same collection windows. The windows limit external API work only; all values +already collected are retained permanently. + +Each eligible analytics publication gets an independent queued job so one +failing API or post cannot block another. The logical uniqueness key is +post-performance + analytics publication + UTC collection date. The job +normalizes only metrics genuinely returned for that network and content type, +preserving unsupported separately from a measured zero. + +Post-performance values are cumulative totals for that analytics publication as +of the collection timestamp. Summary, Top 5 Posts, and Performance use the +latest stored observation for each publication selected by its provider +publication date; they do not add daily snapshots together. + +The normal daily run collects once per UTC day. The final eligible day performs +one final collection before the destination becomes inactive for scheduled +refresh. Transient and rate-limit failures use the same widely spaced, same-day +retry approach as follower collection. If the final-day collection fails, the +latest successful observation remains available with its collection timestamp; +the system does not replace it with zero. + +Metrics already maintained from trusted local events, such as webhook-backed +reaction metadata, may be normalized from that local source without making a +redundant provider request. An included v1 platform without a supported post +metric may still contribute its locally known Posts count and show the metric as +unavailable. An excluded v1 platform contributes neither posts nor metrics. + +## Persistence design + +The physical design uses four tables: three analytics fact/catalog tables and +one operational synchronization table. This is the minimum that keeps account +snapshots, publication identity, cumulative publication metrics, and resumable +job state independent. Combining those lifecycles would either lose history, +reintroduce Redis as durable state, or produce a sparse table that cannot be +aggregated portably on both PostgreSQL and MySQL. + +Database enum types are not used. Enum-backed values are stored in string +columns and cast through PHP enums so new providers and metrics do not require +engine-specific enum migrations. + +The reviewed four-table split is: + +| Table | Cardinality and responsibility | Why it is separate | +| --- | --- | --- | +| `analytics_account_daily_snapshots` | One account/day follower fact | Time-series values and carry-forward provenance | +| `analytics_publications` | One account/provider-post identity | Reconciles TryPost and externally discovered posts once | +| `analytics_publication_daily_snapshots` | One publication/day cumulative metric set | Atomic metric history and portable ranking projections | +| `analytics_sync_states` | One live account/import collector checkpoint | Cursor/high-water coordination only; deleted with the account | + +This is the smallest design that preserves strict keys for each different +cardinality. It intentionally does not introduce a fifth shadow-account table, +does not store workspace totals, and does not mix operational cursors into fact +rows. + +### Daily account snapshots + +`analytics_account_daily_snapshots` stores one effective observation per +workspace, immutable social-account key, and UTC date. It contains: + +- UUID primary key; +- immutable `workspace_id` ownership; +- nullable `social_account_id` foreign key for the currently connected row; +- non-null `social_account_key`, initially copied from the originating + social-account UUID and retained after that row is deleted; +- provider identity snapshot (`network` plus `platform_user_id`) used to resolve + and reuse the historical key when the same provider identity reconnects; +- platform string cast to the existing `SocialAccount\\Platform` enum; +- immutable account presentation snapshots; +- `snapshot_date`; +- nullable `followers_count` bigint; +- nullable JSON `metrics` for future account-level metrics that are not yet + promoted to first-class aggregate columns; +- actual or carried-forward provenance; +- exact or approximate precision; +- provider observation time and collection time. + +The unique key is workspace + social-account key + snapshot date. Platform is a +dimension, not account identity: two Instagram accounts in one workspace remain +two independent series. Cross-network totals sum the latest eligible row for +each social-account key. `social_account_id` is used while the connection +exists; `social_account_key` and the presentation snapshots preserve truthful +historical series after deletion. A connection/reconnection resolver first +looks for an existing key with the same workspace + network + platform user id; +only a genuinely new identity starts with the current social-account UUID. + +### Reconciled publication catalog + +`analytics_publications` stores one publication per workspace, immutable +social-account key, platform, and provider post id. It contains: + +- UUID primary key and immutable `workspace_id` ownership; +- nullable live `social_account_id` plus non-null historical + `social_account_key`; +- nullable unique `post_platform_id` for a TryPost-owned destination; +- platform, normalized network, provider account id, provider post id, provider + publication time, normalized content type, and provider content type; +- origin `trypost` or `external`; +- permalink, excerpt, preview metadata, and immutable account presentation + snapshots; +- available, deleted, or unavailable state; +- first-seen, last-seen, and provider-sync timestamps; +- JSON provider metadata that is not used for cross-network aggregation. + +`external` means only that TryPost did not publish the record. Provider APIs do +not reliably distinguish a manual native-app post from a post created by +Buffer or another client, so the UI says `Published on ` rather than +claiming it was posted manually. A matching `post_platform_id` proves TryPost +origin and produces `Published via TryPost`. + +The unique provider identity is workspace + social-account key + network + +provider post id. Network, rather than login variant, prevents the same +Instagram media from duplicating when an identity reconnects through direct +Instagram instead of Facebook login. A TryPost destination and external +discovery reconcile onto that identity; `trypost` origin wins and the +publication is counted once. + +### Daily publication snapshots + +`analytics_publication_daily_snapshots` stores at most one cumulative +observation per analytics publication and UTC date. Repeated successful +collections on the same date merge into that row under a database row lock +instead of creating additional facts or blanking a metric family collected by +another provider request. It does not duplicate `workspace_id`; workspace +ownership is obtained through the parent analytics publication, preventing an +inconsistent child/parent tenant pair. + +Its portable aggregate projections are nullable big integers for +`reactions_count`, `comments_count`, `shares_count`, `saves_count`, +`views_count`, `impressions_count`, `reach_count`, `engagement_count`, +`exposure_count`, `watch_time_milliseconds`, and +`average_watch_time_milliseconds`, plus nullable `exposure_kind`. A null means +unavailable; a numeric zero means measured zero. Milliseconds are the canonical +duration unit and the UI converts them to minutes or seconds. + +The same row has a JSON metric catalog for provider/content-specific values. +Each JSON entry uses a stable enum-backed metric key and retains numeric value, +unit, provider metric identity, lifetime/range/rolling time basis, +nullable period start/end for range metrics, exact/estimated/experimental +precision, and availability. Cross-network queries use the first-class columns; +the JSON catalog powers the richer individual-post detail. This hybrid avoids +both engine-specific JSON aggregation and an EAV row explosion. + +Imported historical publications receive a real baseline observation collected +at import time. The system does not fabricate daily metric history between the +publication date and that baseline. + +### Synchronization state + +`analytics_sync_states` is deliberately a small operational checkpoint table, +not a general log of every analytics job. It exists only for workflows whose +progress cannot be inferred from fact rows: the finite 365-day publication +backfill and continuing publication discovery. Daily follower success is +represented by an account snapshot, publication-metric success by a publication +snapshot, and attempts/retries by the queue and Horizon; duplicating those in a +sync-state row would create competing sources of truth. + +Each row belongs to one live `social_account_id` with `cascadeOnDelete()` and +one collector (`publication_backfill` or `publication_discovery`). Its columns +are: UUID primary key, non-null social-account foreign key, collector, status, +nullable JSON `checkpoint`, `target_since`, `oldest_reached_at`, +`high_watermark_at`, `last_success_at`, sanitized `last_error_category`, and +timestamps. The unique key is social account + collector. Workspace, platform, +historical account key, next retry, attempt count, and raw error message are not +duplicated here. + +The checkpoint is the one safe JSON boundary for sync control: provider cursor +shapes vary, it is never filtered or aggregated by SQL, and exactly one +collector owns each row. The job locks that row before reading or advancing the +checkpoint. Keeping these rows separate from `social_accounts.meta` prevents +unrelated collectors from overwriting one shared JSON object or locking the +entire account row. + +Operational state does not need to survive deletion. Historical facts remain +in the three analytics tables; deleting the live account removes its obsolete +checkpoint. Reconnecting the same provider identity receives fresh operational +state, while the identity resolver reuses the prior `social_account_key` found +in account snapshots or publications and provider-id uniqueness makes the +restarted import idempotent. + +Backfill and discovery use separate rows and lifecycles. Discovery does not run +while backfill is pending or running. Once backfill reaches a terminal state +(`complete`, `provider_limited`, `partial`, or `failed`), discovery may keep new +content current while a partial backfill is retried independently. + +The X user-post timeline is [limited to 3,200 recent posts](https://docs.x.com/x-api/posts/timelines/introduction). The backfill checkpoint tracks the committed X page count. If the timeline ends at that cap before reaching the 365-day cutoff, coverage is `provider_limited` (`x_timeline_3200`), not complete. Cursor retries preserve the count; a restart from page one resets it. This does not require another table or X API request. + +For every page, a job captures the locked checkpoint and row version, releases +the transaction before the provider request, then locks the row again. It may +upsert publications idempotently, but advances the checkpoint only when the +captured version still matches; a stale duplicate can never move the cursor +backward. + +### Historical identity limitation at rollout + +Existing `post_platforms` rows null `social_account_id` when an account is +deleted and retain only presentation fields, not the provider account id or the +original social-account UUID. Therefore pre-rollout orphan destinations cannot +be assigned to a historical account without guessing from a mutable username. +The local catalog backfill imports only destinations that still have a live +social account and records the omitted-orphan count in rollout logs. It must not +invent an account key or merge rows by username. After rollout, every analytics +publication is written while the account identity is available and remains +historically addressable after later deletion. + +This approved schema must support: + +- workspace-scoped queries; +- social-account breakdown; +- one effective follower value per account and UTC date; +- idempotent writes; +- actual versus carried-forward provenance; +- exact versus approximate precision; +- earliest/latest available workspace dates; +- retaining history after an account is disconnected; +- efficient aggregation at a selected end date; +- latest supported post-performance values per destination; +- permanent retention after a destination leaves its refresh window; +- unsupported versus measured-zero post metrics; +- stable metric keys and units independent of the active UI locale; +- content-type-specific metrics without sparse schema assumptions; +- provider and collection timestamps needed to disclose freshness; +- a reconciled analytics-publication identity spanning TryPost destinations and + imported native posts without duplicating a native post id; +- publication origin, provider publication time, content excerpt, permalink, + content type, presentation metadata, and import coverage state; +- resumable per-account provider cursor/high-water checkpoints; +- efficient workspace, publication-range, account, and ranking aggregations. + +Workspace totals are derived from account observations and are not stored as a +second source of truth. + +The Posts widget does not require daily count snapshots. Its source of truth is +the reconciled analytics-publication catalog: existing successful TryPost +destinations plus imported native posts. A counted record must belong to the +current workspace, use a platform included in analytics v1, have a provider +publication timestamp inside the selected range, and represent one unique +social-account/native-id pair. The concrete persistence and reconciliation +queries must work on PostgreSQL and MySQL. + +## Read path + +`/analytics` reads only local persisted data. It performs no request-time +social API calls for any analytics block. + +The server response supplies: + +- the workspace's available date bounds; +- the effective selected range after validation; +- the follower total at the range end; +- one daily series per social account; +- account identity and platform presentation metadata; +- actual/carried-forward and exact/approximate provenance required for + truthful tooltips; +- reconciled TryPost/native publication totals per social account; +- zero-filled publication buckets and per-account values for the automatically + selected daily, weekly, or monthly resolution; +- current and previous-period Summary values; +- the two deterministic Top 5 rankings; +- Performance rows and comparisons per social account; +- the complete latest metric set for each analytics publication on the + individual post page, REST API, and MCP; +- publication origin, provider publication time, permalink, content type, and + presentation metadata required by native-import detail cards; +- native-history coverage per social account, including progress, oldest + covered publication date, last successful sync, and complete, + provider-limited, partial-failure, or running state; +- freshness and availability metadata needed for tooltips and unavailable + states. + +The frontend derives Bar and Growth from this normalized response instead of +requesting separate endpoints. Large date ranges may later be downsampled, but +daily resolution is the source resolution and is sufficient for this first +version. + +The server aggregates the Posts dataset at the chosen bucket resolution and +returns both bucketed and range-total values. Every analytics-publication row +has its own immutable workspace ownership, including imported native posts +that have no parent TryPost post. A social-account id from the request is never +trusted as the tenancy boundary. + +## Failure handling and observability + +Failures are isolated per social account. One platform outage cannot prevent +other account jobs from succeeding. + +Operational visibility must distinguish: + +- successful actual observation; +- transient failure awaiting retry; +- rate-limited attempt and next eligible attempt time; +- permanent authentication/permission rejection; +- successful carried-forward fallback; +- unavailable account with no historical value; +- finalizer failure; +- post-performance collection success; +- post-performance metric unsupported; +- post-performance retry or permanent collection failure; +- post-performance publication leaving its refresh window with a final stored + value. +- native-history backfill start, page progress, completion, and oldest covered + publication date; +- native-history backfill or incremental-discovery retry and permanent + failure; +- provider-limited history distinguished from a complete 365-day import; +- native publication reconciliation and duplicate suppression. + +Logs include workspace, social account, platform, observation date, attempt, +and error category, but never access tokens or raw sensitive responses. + +The system must make it possible to alert on workspaces that repeatedly rely on +carried-forward values, even though defining an alerting product is outside +this first delivery. + +## Account lifecycle + +- **Connected:** dispatch an immediate first collection. +- **Connected on an included v1 platform:** also dispatch the resumable native + post-history backfill without delaying the connection response. +- **Active and connected:** participate in the daily follower, + post-performance, and native-post discovery sweeps. +- **Deactivated:** stop new collection and fallback; preserve history; exclude + from follower totals after its last eligible date; preserve imported and + TryPost publication history for ranges in which it exists. +- **Disconnected/deleted:** stop collection and fallback; preserve historical + observations and imported publications even if the account row is later + removed. Nullable live foreign keys plus immutable `social_account_key` and + presentation snapshots retain that identity. +- **Reconnected as the same provider identity:** reuse the historical + `social_account_key` without rewriting earlier observations, create fresh + operational checkpoints, and restart the idempotent native import. Existing + publications deduplicate by historical account key + provider post id. +- **New identity:** begins a new series even when its username matches an older + disconnected account. + +## Security and privacy + +- Analytics authorization follows the current workspace membership and policy + model. +- A social account id from another workspace must never affect collection or + read results. +- API tokens remain on `social_accounts` and are never copied into analytics + storage or job logs. +- Platform data-retention terms must be checked as each collector is + implemented; every network-specific constraint is recorded in its collector + tests and coverage status. + +## Testing strategy + +### Collector contract tests + +Each included platform needs tests for: + +- successful exact or approximate follower parsing; +- missing/null metric handling; +- malformed response handling; +- rate-limit classification; +- transient server/connection classification; +- permanent authentication/permission classification; +- no accidental conversion of failure or null to zero. + +HTTP calls are faked. Tests must not call live social APIs. + +Post-performance collector tests additionally cover: + +- native-to-normalized reaction and comment names; +- cumulative metrics stored as one observation rather than summed across days; +- engagement numerator and exposure denominator mapping; +- content-type-specific metric availability; +- Instagram Reel watch-time units and experimental/estimated flags; +- YouTube Short watch time, average duration, average percentage viewed, and + subscriber-change mapping; +- YouTube post labels map likes to Reactions and views to Video Views while the + unavailable native engagement rate remains distinct from TryPost's derived + normalized rate; +- TikTok never fabricating unsupported retention metrics; +- Pinterest saves, comments, reactions, impressions, and native engagement + rate retain their distinct metric identities and time bases; +- Pinterest video Pins map average play time, 10-second plays, 95% plays, and + total play time with explicit units; +- unsupported, missing, malformed, and measured-zero distinctions. + +### Queue and scheduling tests + +- The daily command dispatches one job per eligible account and none for + LinkedIn, Telegram, Discord, Google Business Profile, inactive, disconnected, + or unsupported accounts. +- Jobs are isolated and idempotent by account, metric, and date. +- Retry delays cover the same UTC day and stop after a successful observation. +- Platform-provided retry timing is respected. +- A permanent analytics authentication or permission failure does not follow + the transient retry loop or invalidate an otherwise publishable account. +- Immediate collection is dispatched after a supported account is connected. +- Connecting an included v1 account dispatches a native-history backfill and + returns without waiting for that backfill to finish. +- Rollout dispatches bounded backfills for existing eligible accounts without + requiring reconnection and without placing every workspace in one job. +- No native-history backfill or incremental-discovery job is dispatched for + LinkedIn personal profiles, LinkedIn Pages, Telegram, Discord, or Google + Business Profile. +- Native-history pagination stops at the 365-day cutoff only for an ordered + provider feed; otherwise it follows cursors to provider exhaustion or a + documented provider limit and records which condition ended the import. +- A bounded page can re-dispatch continuation work without holding one worker + for the entire backfill. +- Cursor and high-water checkpoints resume safely after transient failure. +- Deleting an account cascades only its operational checkpoints; reconnecting + the same provider identity creates fresh checkpoints while deduplicating + already imported facts against the reused historical account key. +- Concurrent page jobs compare the captured checkpoint version and cannot move + a cursor backward. +- Daily native discovery overlaps the last completed window and remains + idempotent when a provider returns the same page or a late post twice. +- Backfill and discovery failures for one social account do not block any other + account. +- `withoutOverlapping()` and `onOneServer()` remain present on the schedule. +- Post-performance jobs are dispatched only for reconciled analytics + publications with a usable native id and access on an included v1 platform. +- No post-performance job is dispatched for LinkedIn personal profiles, + LinkedIn Pages, Telegram, Discord, or Google Business Profile. +- X destinations remain eligible through day 20; other supported destinations + remain eligible through day 30. +- The final eligible day receives a final collection and older destinations no + longer create provider jobs. +- Collection-window expiry never deletes an already stored value. +- A newly imported publication still inside the refresh window joins the + normal daily metric collection. +- An older backfilled publication receives at most the planned baseline metric + collection and is not enrolled in perpetual refresh. +- Unsupported metrics remain distinct from measured zero. +- Instagram Story jobs collect while insights are available and perform a + final pre-expiry collection even when the normal daily sweep would miss it. + +### Fallback tests + +- The finalizer carries forward the last known value after all daily attempts + fail. +- Carried-forward provenance is preserved. +- No historical value means no fabricated snapshot. +- Deactivated and disconnected accounts are not carried forward. +- A successful observation is never overwritten by the finalizer. +- Importing historical posts never creates historical follower observations or + follower carry-forward values before the first real collection. + +### Native publication reconciliation tests + +- Repeated provider pages create one analytics publication for each unique + workspace, social account, platform, and native post id. +- A native id matching an existing successful TryPost destination reconciles + to one analytics publication and retains `trypost` as its origin. +- A TryPost destination that receives its native id after discovery reconciles + with the imported publication rather than creating a duplicate. +- Reconnecting the same identity resumes the existing catalog; a genuinely new + platform identity starts a separate catalog even when the username matches. +- Pre-rollout TryPost destinations already orphaned from their social account + are reported and skipped instead of being guessed or grouped by username. +- Imported publications never create fake `Post` or `PostPlatform` lifecycle + records and cannot be edited, deleted, retried, or published from TryPost. +- The provider publication timestamp, rather than discovery time, controls + range inclusion and aggregation. +- Expired or unavailable preview media falls back to a stable placeholder + without removing the imported publication or its metrics. + +### Read and UI tests + +- Every response is workspace-scoped. +- The total sums each eligible account once on the selected end date. +- Multiple accounts on one network remain separate. +- Date bounds reflect the workspace's actual stored history. +- Line, Bar, and Growth derive the expected values from the same dataset. +- Growth handles negative values and a zero baseline. +- A later-connected account does not receive invented earlier points. +- Historical series remain available after disconnect/deactivation. +- No-data workspaces receive the collection-pending state. +- LinkedIn personal profiles, LinkedIn Pages, Telegram, Discord, and Google + Business Profile never appear or contribute anywhere in analytics v1. +- The Posts Bar mode counts one reconciled TryPost/native analytics publication + per social account in the selected range. +- The Posts Stacked Bar mode selects daily, weekly, and monthly buckets at the + documented range thresholds and zero-fills missing buckets. +- A multi-network post contributes once to every successful destination on an + included v1 platform and nothing to excluded, failed, rejected, pending, or + future-scheduled destinations. +- A destination that succeeds after retries counts only once. +- Direct/native social-network posts are included after discovery even though + no TryPost publication record exists for them. +- Native imports appear incrementally while backfill is running, and the UI + exposes oldest-covered date, last sync, and complete, provider-limited, + partial-failure, or running coverage state without promising unavailable + history. +- Publications for every excluded v1 platform are absent from both Posts widget + modes, Summary, Top 5, and Performance. +- Post aggregation is workspace-scoped through immutable analytics-publication + ownership for both TryPost and native imports. +- Historical publications retain presentable account information after the + social account is disconnected or deleted. +- Summary contains exactly Posts, Total Followers, Reactions, Comments, and + Engagement Rate. +- Summary compares against the immediately preceding inclusive range of equal + length and handles zero, unavailable, and partial previous data safely. +- Engagement Rate pools normalized engagement and exposure rather than + averaging per-post percentages. +- Posts without a valid exposure denominator are excluded only from the rate. +- Top 5 ranks reconciled analytics publications deterministically by Reactions + or Comments, includes eligible native imports, and excludes unsupported + values. +- Imported Top 5 and detail cards show `Published on `, while + reconciled TryPost publications show `Published via TryPost`; origin is read + from persisted provenance rather than inferred from missing relations. +- Imported publication cards expose no fake TryPost edit, retry, or publishing + action. +- Performance returns one row per social account, keeps duplicate-network + accounts separate, includes reconciled native publications, supports sorting, + and uses the same aggregation rules as Summary. +- The individual post page, REST API, and MCP return the same persisted latest + observation and make no provider request during reads. +- The individual post page exposes no analytics block for a destination on an + excluded v1 platform. +- The individual post page shows the content-type-specific catalog, canonical + units, freshness, and metric stability/provenance. +- Date-picker bounds expand to the earliest eligible imported provider + publication date as backfill progresses. +- Expired Redis entries cannot remove or change persisted post analytics. + +Database-dependent tests run on PostgreSQL and MySQL after the persistence +design is approved and implemented. + +## Considered and rejected + +- **Calling social APIs from `/analytics`.** Slow, rate-limit prone, impossible + to trend reliably, and couples page availability to every provider. +- **One queued job per workspace.** A single slow or broken account delays the + whole workspace and makes retries unnecessarily broad. +- **Fast retry loops.** Follower totals tolerate delay; four-hour spacing gives + providers time to recover and protects API quotas. +- **Writing zero on failure.** Produces false losses and corrupts totals. +- **Silently using an old observation without provenance.** Keeps the UI full + but makes stale data indistinguishable from measured data. +- **Deleting history when an account disconnects.** Removes valid workspace + history and breaks historical comparisons. +- **Persisting workspace totals.** Duplicates account facts and risks drift. +- **One generic analytics table.** Account snapshots, publication identity, + cumulative publication metrics, and resumable cursors have different + cardinality and lifecycle. Combining them creates sparse rows and weak + constraints. The four-table hybrid is the minimum safe design. +- **A fifth `analytics_accounts` dimension table.** It would normalize repeated + identity/presentation columns and is defensible at warehouse scale, but every + fact would then need another join and the application would maintain a shadow + account lifecycle solely for analytics. The current four-table design keeps + the minimum table count while repeating only small immutable snapshots. +- **Putting collector cursors in `social_accounts.meta`.** Multiple collectors + would contend on one account row and could overwrite independent JSON + branches. One checkpoint row per collector gives a narrow lock and a unique + owner without turning sync state into a general job log. +- **Persisting sync state for follower and publication-metric jobs.** Their fact + snapshots already prove successful work, while queue/Horizon records attempts + and failures. Duplicating that status would create drift and extra writes. +- **Preserving sync-state rows after account deletion.** Checkpoints are + operational, not historical facts. Cascading them prevents dead work from + appearing resumable; a reconnect safely restarts against idempotent facts. +- **Guessing pre-rollout orphan identity from username.** Usernames can change + or be reused, and existing orphaned `post_platforms` lack the provider account + id. Skipping and reporting those rows is more truthful than merging unrelated + accounts. +- **JSON-only post metrics.** Cross-network ranking and aggregation would depend + on engine-specific JSON queries. Common aggregate fields are first-class + nullable columns; provider/content-specific metrics remain structured JSON. +- **An EAV row for every metric.** It would multiply row volume and joins for + every post card. One daily publication snapshot keeps the metric set atomic. +- **Including LinkedIn, Telegram, Discord, or Google Business Profile in v1.** + LinkedIn requires the separately vetted product for a coherent implementation; + the other three are outside the chosen product scope and lack a Buffer + per-post analytics reference. LinkedIn is deferred as a whole to v2; the + others require a new future product decision. +- **Keeping Posts limited to TryPost deliveries.** It would make a newly + connected workspace look empty and omit the user's best historical content. + A native-history importer gives Summary, Top 5, Performance, and individual + post analytics useful data immediately while preserving publication origin. +- **Counting parent posts.** One parent can target several accounts and can + partially fail, so the successful destination is the only accurate unit. +- **Persisting daily post-count snapshots.** Publication rows are immutable + facts in the reconciled catalog and can be aggregated for the selected range + without introducing a second source of truth. +- **Reusing `PollRepurposeSource` and `RepurposeItem` for analytics.** Repurpose + imports selected media for a publishing workflow, currently reads one page + of 25, and has activation-watermark and media-download semantics that do not + represent a complete, read-only analytics catalog. Only suitable low-level + provider clients and parsers may be extracted and shared. +- **Creating fake `Post` or `PostPlatform` rows for native content.** Those + records imply TryPost publishing ownership and would expose invalid edit, + retry, delete, and repurpose actions. Native content remains an analytics + publication with explicit origin. +- **Claiming one year of coverage unconditionally.** Providers can impose + shallower history, pagination, permission, or metric-retention limits. The + importer targets 365 days but reports the actual oldest covered date and a + provider-limited or partial state when needed. +- **Using one fixed Posts bucket size.** A fixed daily view becomes noisy over + long ranges, while a fixed weekly or monthly view hides useful short-range + detail. +- **Refreshing every imported historical post forever.** Engagement changes + slow after publication, while an unbounded daily job set would continually + increase API cost and rate-limit pressure. Older backfilled posts receive a + bounded baseline collection; the last stored result remains available after + the 20/30-day refresh window closes. +- **Applying plan-based analytics retention.** TryPost has no free analytics + tier in this design; collection and permanent local retention are consistent + for every workspace. +- **Averaging individual engagement rates.** It overweights posts with little + exposure. Pooling the engagement and exposure totals produces a weighted + workspace/account rate. +- **Treating unsupported metrics as zero.** Zero means the provider measured no + activity; unsupported means no measurement was available and must remain + visibly different. +- **Keeping the individual post page on request-time API calls and Redis.** It + would give the post page a different source and freshness model from Summary, + Top 5 Posts, Performance, REST, and MCP. All consumers must converge on the + persisted observation. +- **Reducing post persistence to the five cross-network fields.** That would + discard high-value, content-specific metrics such as Reel/Short watch time + and make the individual post page less useful than the provider data already + available to TryPost. +- **Inferring TikTok retention from duration and views.** Video length describes + the asset, not how long viewers watched it; the approved integration exposes + no retention metric. + +## External references checked + +- Meta Instagram Media Insights, updated September 11, 2026: + +- YouTube Analytics metrics and channel report combinations: + and + +- YouTube Analytics data freshness and Data API video statistics: + , + , and + +- TikTok Display API video list/query and Video Object fields: + , + , and + +- Pinterest organic reporting and metric definitions: + + and + +- Pinterest's official generated API client, including `pin_metrics` lifetime + comments/reactions and Pin Analytics parameters: + +- Buffer sent-post metric matrix, Insights behavior, and documented + cross-surface/provider differences: + , + , + and + + +## Delivery gates + +1. This written design must be reviewed and approved. +2. Before promising native-history import for a v1 platform, verify in that + platform's current official documentation the owned-post enumeration + endpoint, pagination, scopes, accessible content types, history depth, + metric-retention limits, and preview-media expiry. Record any shallower + provider limit in the coverage contract instead of weakening it silently. +3. The implementation plan must preserve the approved four-table hybrid, + reconciled TryPost/external publication identity, and resumable import + checkpoints. +4. Implementation begins only after that written plan is reviewed and its + execution method is selected. +5. LinkedIn follower and post analytics receive a separate v2 implementation + plan after the external Community Management API dependency is resolved. diff --git a/lang/ar/analytics.php b/lang/ar/analytics.php index adee88c5c..f4223b518 100644 --- a/lang/ar/analytics.php +++ b/lang/ar/analytics.php @@ -1,6 +1,106 @@ 'التحليلات', + 'detail' => [ + 'page_title' => 'تحليلات :platform', + 'content_types' => [ + 'text' => 'نص', + 'image' => 'صورة', + 'carousel' => 'عرض متتابع', + 'video' => 'فيديو', + 'reel' => 'ريلز', + 'story' => 'قصة', + 'short' => 'فيديو قصير', + 'link' => 'رابط', + 'poll' => 'استطلاع', + 'unknown' => 'غير معروف', + ], + 'time_basis' => [ + 'lifetime' => 'منذ النشر', + 'range' => 'الفترة المحددة', + 'rolling_90_days' => 'آخر 90 يوماً', + 'snapshot' => 'لقطة بيانات', + ], + 'labels' => [ + 'watch_time_milliseconds' => 'مدة المشاهدة', + 'average_watch_time_milliseconds' => 'متوسط مدة المشاهدة', + 'total_play_time_milliseconds' => 'إجمالي وقت التشغيل', + 'average_video_play_time_milliseconds' => 'متوسط وقت التشغيل', + 'average_percentage_viewed' => 'متوسط النسبة المشاهدة', + 'video_views_10_seconds' => 'مشاهدات لمدة 10 ثوانٍ', + 'video_views_95_percent' => 'مشاهدات حتى 95٪', + 'video_quartile_25' => 'تمت مشاهدة 25٪', + 'video_quartile_50' => 'تمت مشاهدة 50٪', + 'video_quartile_75' => 'تمت مشاهدة 75٪', + 'video_quartile_100' => 'تمت مشاهدة 100٪', + 'story_taps_forward' => 'النقرات للتقدم', + 'story_taps_back' => 'النقرات للرجوع', + 'story_swipes_forward' => 'السحب للتقدم', + 'engagements' => 'التفاعلات', + 'total_interactions' => 'إجمالي التفاعلات', + 'engagement_rate' => 'معدل التفاعل', + 'link_clicks' => 'النقرات على الروابط', + 'outbound_click_rate' => 'معدل النقرات الخارجية', + 'save_rate' => 'معدل الحفظ', + 'follows' => 'المتابعون الجدد', + 'profile_visits' => 'زيارات الملف الشخصي', + 'profile_activity' => 'نشاط الملف الشخصي', + 'skip_rate' => 'معدل التخطي', + 'engaged_views' => 'المشاهدات المتفاعلة', + 'total_audience' => 'إجمالي الجمهور', + 'engaged_audience' => 'الجمهور المتفاعل', + 'story_navigation' => 'التنقل في القصص', + 'story_exits' => 'الخروج من القصص', + 'unique_viewers' => 'المشاهدون الفريدون', + ], + 'engagement' => 'التفاعل', + 'exposure' => 'الوصول', + 'video' => 'الفيديو والاحتفاظ بالمشاهدين', + 'last_collected' => 'آخر جمع للبيانات', + 'stale' => 'قد تكون البيانات قديمة', + 'awaiting_metrics' => 'لم تُجمع المقاييس بعد.', + 'estimated' => 'تقديري', + 'back_to_analytics' => 'العودة إلى التحليلات', + 'details' => 'التفاصيل', + 'published_via_trypost' => 'نُشر عبر TryPost', + 'published_on' => 'نُشر على :platform', + ], + 'dashboard' => [ + 'summary' => 'ملخص', + 'posts' => 'المنشورات', + 'total_followers' => 'إجمالي المتابعين', + 'reactions' => 'التفاعلات', + 'comments' => 'التعليقات', + 'engagement_rate' => 'معدل التفاعل', + 'followers' => 'المتابعون', + 'performance' => 'الأداء', + 'top_posts' => 'أفضل 5 منشورات', + 'channel' => 'القناة', + 'workspace_description' => 'جميع قنواتك المتصلة مع عرض كل حساب على حدة.', + 'latest_snapshot_hint' => 'تستخدم مقاييس المنشورات أحدث قياس محفوظ للمنشورات المنشورة خلال هذه الفترة.', + 'followers_chart_mode' => 'وضع مخطط المتابعين', + 'posts_chart_mode' => 'وضع مخطط المنشورات', + 'chart_line' => 'خطي', + 'chart_bar' => 'أعمدة', + 'chart_growth' => 'النمو', + 'chart_stacked_bar' => 'أعمدة مكدسة', + 'followers_line_description' => 'المتابعون لكل حساب بمرور الوقت', + 'no_follower_data' => 'لا يتوفر سجل للمتابعين لهذه الفترة.', + 'no_post_data' => 'لا توجد منشورات منشورة خلال هذه الفترة.', + 'no_ranked_posts' => 'لا توجد منشورات ذات تفاعلات أو تعليقات مقاسة خلال هذه الفترة.', + 'no_performance' => 'لا تتوفر بيانات أداء للقنوات خلال هذه الفترة.', + 'no_excerpt' => 'لا تتوفر معاينة للنص.', + 'published_via_trypost' => 'نُشر عبر TryPost', + 'published_on_network' => 'نُشر على الشبكة الاجتماعية', + 'view_post' => 'عرض المنشور', + 'top_posts_sort' => 'ترتيب المنشورات حسب', + 'carried_forward' => 'آخر قيمة معروفة', + 'carried_forward_hint' => 'تعذّر الوصول إلى الخدمة؛ هذا هو آخر عدد معروف للمتابعين.', + 'import_in_progress' => 'جارٍ استيراد سجل الحساب. ستظهر المنشورات الأقدم مع تقدم جمع البيانات.', + 'no_data_title' => 'يجري إعداد سجل التحليلات لديك', + 'no_data_body' => 'اربط حسابًا مدعومًا أو انتظر أول عملية جمع للبيانات في الخلفية. ستظهر المنشورات السابقة أثناء الاستيراد.', + ], 'no_accounts' => 'لا توجد حسابات متصلة تحتوي على تحليلات.', 'no_accounts_match' => 'لا توجد حسابات مطابقة.', 'search_account' => 'البحث عن حساب…', diff --git a/lang/de/analytics.php b/lang/de/analytics.php index 8d9de7433..3b7cdc59e 100644 --- a/lang/de/analytics.php +++ b/lang/de/analytics.php @@ -3,6 +3,106 @@ declare(strict_types=1); return [ + 'title' => 'Analysen', + 'detail' => [ + 'page_title' => ':platform-Analysen', + 'content_types' => [ + 'text' => 'Text', + 'image' => 'Bild', + 'carousel' => 'Karussell', + 'video' => 'Video', + 'reel' => 'Reel', + 'story' => 'Story', + 'short' => 'Short', + 'link' => 'Link', + 'poll' => 'Umfrage', + 'unknown' => 'Unbekannt', + ], + 'time_basis' => [ + 'lifetime' => 'Seit Veröffentlichung', + 'range' => 'Ausgewählter Zeitraum', + 'rolling_90_days' => 'Letzte 90 Tage', + 'snapshot' => 'Momentaufnahme', + ], + 'labels' => [ + 'watch_time_milliseconds' => 'Wiedergabezeit', + 'average_watch_time_milliseconds' => 'Durchschnittliche Wiedergabezeit', + 'total_play_time_milliseconds' => 'Gesamte Abspielzeit', + 'average_video_play_time_milliseconds' => 'Durchschnittliche Abspielzeit', + 'average_percentage_viewed' => 'Durchschnittlich angesehen', + 'video_views_10_seconds' => 'Aufrufe ab 10 Sekunden', + 'video_views_95_percent' => 'Aufrufe bis 95 %', + 'video_quartile_25' => '25 % angesehen', + 'video_quartile_50' => '50 % angesehen', + 'video_quartile_75' => '75 % angesehen', + 'video_quartile_100' => '100 % angesehen', + 'story_taps_forward' => 'Tippen zum Vorwärtsblättern', + 'story_taps_back' => 'Tippen zum Zurückblättern', + 'story_swipes_forward' => 'Wischen zum Vorwärtsblättern', + 'engagements' => 'Interaktionen', + 'total_interactions' => 'Interaktionen insgesamt', + 'engagement_rate' => 'Interaktionsrate', + 'link_clicks' => 'Linkklicks', + 'outbound_click_rate' => 'Rate externer Klicks', + 'save_rate' => 'Speicherrate', + 'follows' => 'Neue Follower', + 'profile_visits' => 'Profilbesuche', + 'profile_activity' => 'Profilaktivität', + 'skip_rate' => 'Überspringrate', + 'engaged_views' => 'Interaktive Aufrufe', + 'total_audience' => 'Gesamte Zielgruppe', + 'engaged_audience' => 'Interagierende Zielgruppe', + 'story_navigation' => 'Story-Navigation', + 'story_exits' => 'Story-Ausstiege', + 'unique_viewers' => 'Eindeutige Zuschauer', + ], + 'engagement' => 'Interaktionen', + 'exposure' => 'Reichweite', + 'video' => 'Video und Zuschauerbindung', + 'last_collected' => 'Zuletzt erfasst', + 'stale' => 'Möglicherweise veraltet', + 'awaiting_metrics' => 'Es wurden noch keine Kennzahlen erfasst.', + 'estimated' => 'Geschätzt', + 'back_to_analytics' => 'Zurück zu den Analysen', + 'details' => 'Details', + 'published_via_trypost' => 'Über TryPost veröffentlicht', + 'published_on' => 'Auf :platform veröffentlicht', + ], + 'dashboard' => [ + 'summary' => 'Übersicht', + 'posts' => 'Beiträge', + 'total_followers' => 'Follower insgesamt', + 'reactions' => 'Reaktionen', + 'comments' => 'Kommentare', + 'engagement_rate' => 'Interaktionsrate', + 'followers' => 'Follower', + 'performance' => 'Leistung', + 'top_posts' => 'Top 5 Beiträge', + 'channel' => 'Kanal', + 'workspace_description' => 'Alle verbundenen Kanäle zusammen, wobei jedes Konto einzeln ausgewiesen wird.', + 'latest_snapshot_hint' => 'Für Beiträge in diesem Zeitraum wird die zuletzt gespeicherte Messung verwendet.', + 'followers_chart_mode' => 'Ansicht des Follower-Diagramms', + 'posts_chart_mode' => 'Ansicht des Beitragsdiagramms', + 'chart_line' => 'Linie', + 'chart_bar' => 'Balken', + 'chart_growth' => 'Wachstum', + 'chart_stacked_bar' => 'Gestapelte Balken', + 'followers_line_description' => 'Follower je Konto im Zeitverlauf', + 'no_follower_data' => 'Für diesen Zeitraum ist kein Follower-Verlauf verfügbar.', + 'no_post_data' => 'In diesem Zeitraum wurden keine Beiträge veröffentlicht.', + 'no_ranked_posts' => 'Keine Beiträge mit erfassten Reaktionen oder Kommentaren in diesem Zeitraum.', + 'no_performance' => 'Keine Kanalleistung für diesen Zeitraum verfügbar.', + 'no_excerpt' => 'Keine Textvorschau verfügbar.', + 'published_via_trypost' => 'Über TryPost veröffentlicht', + 'published_on_network' => 'Im sozialen Netzwerk veröffentlicht', + 'view_post' => 'Beitrag ansehen', + 'top_posts_sort' => 'Beiträge sortieren nach', + 'carried_forward' => 'Letzter bekannter Wert', + 'carried_forward_hint' => 'Der Anbieter war nicht verfügbar; dies ist die letzte bekannte Follower-Zahl.', + 'import_in_progress' => 'Der Kontoverlauf wird importiert. Ältere Beiträge erscheinen, während die Erfassung fortschreitet.', + 'no_data_title' => 'Dein Analyseverlauf wird vorbereitet', + 'no_data_body' => 'Verbinde ein unterstütztes Konto oder warte auf die erste Erfassung im Hintergrund. Frühere Beiträge erscheinen während des Imports.', + ], 'no_accounts' => 'Keine verbundenen Konten mit Analysedaten.', 'no_accounts_match' => 'Keine passenden Konten.', 'search_account' => 'Konto suchen…', diff --git a/lang/el/analytics.php b/lang/el/analytics.php index af402c594..cfe68d5f7 100644 --- a/lang/el/analytics.php +++ b/lang/el/analytics.php @@ -1,6 +1,106 @@ 'Στατιστικά', + 'detail' => [ + 'page_title' => 'Αναλυτικά στοιχεία :platform', + 'content_types' => [ + 'text' => 'Κείμενο', + 'image' => 'Εικόνα', + 'carousel' => 'Καρουζέλ', + 'video' => 'Βίντεο', + 'reel' => 'Reel', + 'story' => 'Ιστορία', + 'short' => 'Short', + 'link' => 'Σύνδεσμος', + 'poll' => 'Δημοσκόπηση', + 'unknown' => 'Άγνωστο', + ], + 'time_basis' => [ + 'lifetime' => 'Από τη δημοσίευση', + 'range' => 'Επιλεγμένη περίοδος', + 'rolling_90_days' => 'Τελευταίες 90 ημέρες', + 'snapshot' => 'Στιγμιότυπο', + ], + 'labels' => [ + 'watch_time_milliseconds' => 'Χρόνος παρακολούθησης', + 'average_watch_time_milliseconds' => 'Μέσος χρόνος παρακολούθησης', + 'total_play_time_milliseconds' => 'Συνολικός χρόνος αναπαραγωγής', + 'average_video_play_time_milliseconds' => 'Μέσος χρόνος αναπαραγωγής', + 'average_percentage_viewed' => 'Μέσο ποσοστό προβολής', + 'video_views_10_seconds' => 'Προβολές 10 δευτερολέπτων', + 'video_views_95_percent' => 'Προβολές έως το 95%', + 'video_quartile_25' => 'Παρακολούθηση 25%', + 'video_quartile_50' => 'Παρακολούθηση 50%', + 'video_quartile_75' => 'Παρακολούθηση 75%', + 'video_quartile_100' => 'Παρακολούθηση 100%', + 'story_taps_forward' => 'Αγγίγματα προς τα εμπρός', + 'story_taps_back' => 'Αγγίγματα προς τα πίσω', + 'story_swipes_forward' => 'Σαρώσεις προς τα εμπρός', + 'engagements' => 'Αλληλεπιδράσεις', + 'total_interactions' => 'Σύνολο αλληλεπιδράσεων', + 'engagement_rate' => 'Ποσοστό αλληλεπίδρασης', + 'link_clicks' => 'Κλικ σε συνδέσμους', + 'outbound_click_rate' => 'Ποσοστό εξερχόμενων κλικ', + 'save_rate' => 'Ποσοστό αποθηκεύσεων', + 'follows' => 'Νέοι ακόλουθοι', + 'profile_visits' => 'Επισκέψεις προφίλ', + 'profile_activity' => 'Δραστηριότητα προφίλ', + 'skip_rate' => 'Ποσοστό παράλειψης', + 'engaged_views' => 'Προβολές με αλληλεπίδραση', + 'total_audience' => 'Συνολικό κοινό', + 'engaged_audience' => 'Κοινό με αλληλεπίδραση', + 'story_navigation' => 'Πλοήγηση ιστορίας', + 'story_exits' => 'Έξοδοι από ιστορίες', + 'unique_viewers' => 'Μοναδικοί θεατές', + ], + 'engagement' => 'Αλληλεπίδραση', + 'exposure' => 'Απήχηση', + 'video' => 'Βίντεο και διατήρηση κοινού', + 'last_collected' => 'Τελευταία συλλογή', + 'stale' => 'Τα δεδομένα μπορεί να μην είναι ενημερωμένα', + 'awaiting_metrics' => 'Δεν έχουν συλλεχθεί ακόμη μετρήσεις.', + 'estimated' => 'Εκτίμηση', + 'back_to_analytics' => 'Πίσω στα αναλυτικά στοιχεία', + 'details' => 'Λεπτομέρειες', + 'published_via_trypost' => 'Δημοσιεύτηκε μέσω TryPost', + 'published_on' => 'Δημοσιεύτηκε στο :platform', + ], + 'dashboard' => [ + 'summary' => 'Σύνοψη', + 'posts' => 'Δημοσιεύσεις', + 'total_followers' => 'Σύνολο ακολούθων', + 'reactions' => 'Αντιδράσεις', + 'comments' => 'Σχόλια', + 'engagement_rate' => 'Ποσοστό αλληλεπίδρασης', + 'followers' => 'Ακόλουθοι', + 'performance' => 'Απόδοση', + 'top_posts' => '5 κορυφαίες δημοσιεύσεις', + 'channel' => 'Κανάλι', + 'workspace_description' => 'Όλα τα συνδεδεμένα κανάλια μαζί, με κάθε λογαριασμό να εμφανίζεται ξεχωριστά.', + 'latest_snapshot_hint' => 'Οι μετρήσεις των δημοσιεύσεων χρησιμοποιούν την πιο πρόσφατη αποθηκευμένη μέτρηση για αυτή την περίοδο.', + 'followers_chart_mode' => 'Τρόπος εμφάνισης γραφήματος ακολούθων', + 'posts_chart_mode' => 'Τρόπος εμφάνισης γραφήματος δημοσιεύσεων', + 'chart_line' => 'Γραμμή', + 'chart_bar' => 'Ράβδοι', + 'chart_growth' => 'Ανάπτυξη', + 'chart_stacked_bar' => 'Στοιβαγμένες ράβδοι', + 'followers_line_description' => 'Ακόλουθοι ανά λογαριασμό με την πάροδο του χρόνου', + 'no_follower_data' => 'Δεν υπάρχει ιστορικό ακολούθων για αυτή την περίοδο.', + 'no_post_data' => 'Δεν υπάρχουν δημοσιευμένες αναρτήσεις για αυτή την περίοδο.', + 'no_ranked_posts' => 'Δεν υπάρχουν δημοσιεύσεις με μετρημένες αντιδράσεις ή σχόλια για αυτή την περίοδο.', + 'no_performance' => 'Δεν υπάρχουν δεδομένα απόδοσης ανά κανάλι για αυτή την περίοδο.', + 'no_excerpt' => 'Δεν υπάρχει διαθέσιμη προεπισκόπηση κειμένου.', + 'published_via_trypost' => 'Δημοσιεύτηκε μέσω TryPost', + 'published_on_network' => 'Δημοσιεύτηκε στο κοινωνικό δίκτυο', + 'view_post' => 'Προβολή δημοσίευσης', + 'top_posts_sort' => 'Ταξινόμηση δημοσιεύσεων κατά', + 'carried_forward' => 'Τελευταία γνωστή τιμή', + 'carried_forward_hint' => 'Η υπηρεσία δεν ήταν διαθέσιμη· αυτός είναι ο τελευταίος γνωστός αριθμός ακολούθων.', + 'import_in_progress' => 'Γίνεται εισαγωγή του ιστορικού λογαριασμού. Οι παλαιότερες δημοσιεύσεις θα εμφανιστούν καθώς προχωρά η συλλογή.', + 'no_data_title' => 'Προετοιμάζουμε το ιστορικό αναλυτικών στοιχείων', + 'no_data_body' => 'Συνδέστε έναν υποστηριζόμενο λογαριασμό ή περιμένετε την πρώτη συλλογή στο παρασκήνιο. Οι παλαιότερες δημοσιεύσεις θα εμφανιστούν κατά την εισαγωγή.', + ], 'no_accounts' => 'Δεν υπάρχουν συνδεδεμένοι λογαριασμοί με στατιστικά.', 'no_accounts_match' => 'Δεν ταιριάζει κανένας λογαριασμός.', 'search_account' => 'Αναζήτηση λογαριασμού…', diff --git a/lang/en/analytics.php b/lang/en/analytics.php index 3212f6cac..bda41ad6b 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -1,6 +1,106 @@ 'Analytics', + 'detail' => [ + 'page_title' => ':platform analytics', + 'content_types' => [ + 'text' => 'Text', + 'image' => 'Image', + 'carousel' => 'Carousel', + 'video' => 'Video', + 'reel' => 'Reel', + 'story' => 'Story', + 'short' => 'Short', + 'link' => 'Link', + 'poll' => 'Poll', + 'unknown' => 'Unknown', + ], + 'time_basis' => [ + 'lifetime' => 'Lifetime', + 'range' => 'Selected period', + 'rolling_90_days' => 'Last 90 days', + 'snapshot' => 'Snapshot', + ], + 'labels' => [ + 'watch_time_milliseconds' => 'Watch time', + 'average_watch_time_milliseconds' => 'Avg. watch time', + 'total_play_time_milliseconds' => 'Total play time', + 'average_video_play_time_milliseconds' => 'Avg. video play time', + 'average_percentage_viewed' => 'Avg. viewed', + 'video_views_10_seconds' => '10-second views', + 'video_views_95_percent' => '95% views', + 'video_quartile_25' => '25% watched', + 'video_quartile_50' => '50% watched', + 'video_quartile_75' => '75% watched', + 'video_quartile_100' => '100% watched', + 'story_taps_forward' => 'Taps forward', + 'story_taps_back' => 'Taps back', + 'story_swipes_forward' => 'Swipes forward', + 'engagements' => 'Engagements', + 'total_interactions' => 'Total interactions', + 'engagement_rate' => 'Engagement rate', + 'link_clicks' => 'Link clicks', + 'outbound_click_rate' => 'Outbound click rate', + 'save_rate' => 'Save rate', + 'follows' => 'Follows', + 'profile_visits' => 'Profile visits', + 'profile_activity' => 'Profile activity', + 'skip_rate' => 'Skip rate', + 'engaged_views' => 'Engaged views', + 'total_audience' => 'Total audience', + 'engaged_audience' => 'Engaged audience', + 'story_navigation' => 'Story navigation', + 'story_exits' => 'Story exits', + 'unique_viewers' => 'Unique viewers', + ], + 'engagement' => 'Engagement', + 'exposure' => 'Exposure', + 'video' => 'Video and retention', + 'last_collected' => 'Last collected', + 'stale' => 'May be out of date', + 'awaiting_metrics' => 'Metrics have not been collected yet.', + 'estimated' => 'Estimated', + 'back_to_analytics' => 'Back to analytics', + 'details' => 'Details', + 'published_via_trypost' => 'Published via TryPost', + 'published_on' => 'Published on :platform', + ], + 'dashboard' => [ + 'summary' => 'Summary', + 'posts' => 'Posts', + 'total_followers' => 'Total Followers', + 'reactions' => 'Reactions', + 'comments' => 'Comments', + 'engagement_rate' => 'Eng. Rate', + 'followers' => 'Followers', + 'performance' => 'Performance', + 'top_posts' => 'Top 5 Posts', + 'channel' => 'Channel', + 'workspace_description' => 'Your connected channels together, with each account kept distinct.', + 'latest_snapshot_hint' => 'Post metrics use the latest saved observation for posts published in this range.', + 'followers_chart_mode' => 'Follower chart mode', + 'posts_chart_mode' => 'Post chart mode', + 'chart_line' => 'Line', + 'chart_bar' => 'Bar', + 'chart_growth' => 'Growth', + 'chart_stacked_bar' => 'Stacked bar', + 'followers_line_description' => 'Followers by account over time', + 'no_follower_data' => 'Follower history is not available for this period.', + 'no_post_data' => 'No published posts in this period.', + 'no_ranked_posts' => 'No posts with measured reactions or comments in this period.', + 'no_performance' => 'No channel performance in this period.', + 'no_excerpt' => 'No text preview available.', + 'published_via_trypost' => 'Published via TryPost', + 'published_on_network' => 'Published on the social network', + 'view_post' => 'View post', + 'top_posts_sort' => 'Rank posts by', + 'carried_forward' => 'Last known', + 'carried_forward_hint' => 'The provider was unavailable; this is the last known follower count.', + 'import_in_progress' => 'Importing account history. Older posts may appear as collection continues.', + 'no_data_title' => 'Your analytics history is being prepared', + 'no_data_body' => 'Connect a supported account or wait for the first background collection. Historical posts appear as they are imported.', + ], 'no_accounts' => 'No connected accounts with analytics.', 'no_accounts_match' => 'No accounts match.', 'search_account' => 'Search account…', diff --git a/lang/es/analytics.php b/lang/es/analytics.php index 5b97ec60c..da97d93ed 100644 --- a/lang/es/analytics.php +++ b/lang/es/analytics.php @@ -1,6 +1,106 @@ 'Estadísticas', + 'detail' => [ + 'page_title' => 'Estadísticas de :platform', + 'content_types' => [ + 'text' => 'Texto', + 'image' => 'Imagen', + 'carousel' => 'Carrusel', + 'video' => 'Vídeo', + 'reel' => 'Reel', + 'story' => 'Historia', + 'short' => 'Short', + 'link' => 'Enlace', + 'poll' => 'Encuesta', + 'unknown' => 'Desconocido', + ], + 'time_basis' => [ + 'lifetime' => 'Desde la publicación', + 'range' => 'Período seleccionado', + 'rolling_90_days' => 'Últimos 90 días', + 'snapshot' => 'Medición puntual', + ], + 'labels' => [ + 'watch_time_milliseconds' => 'Tiempo de visualización', + 'average_watch_time_milliseconds' => 'Tiempo medio de visualización', + 'total_play_time_milliseconds' => 'Tiempo total de reproducción', + 'average_video_play_time_milliseconds' => 'Tiempo medio de reproducción', + 'average_percentage_viewed' => 'Porcentaje medio visto', + 'video_views_10_seconds' => 'Visualizaciones de 10 segundos', + 'video_views_95_percent' => 'Visualizaciones al 95 %', + 'video_quartile_25' => '25 % visto', + 'video_quartile_50' => '50 % visto', + 'video_quartile_75' => '75 % visto', + 'video_quartile_100' => '100 % visto', + 'story_taps_forward' => 'Toques para avanzar', + 'story_taps_back' => 'Toques para retroceder', + 'story_swipes_forward' => 'Deslizamientos para avanzar', + 'engagements' => 'Interacciones', + 'total_interactions' => 'Interacciones totales', + 'engagement_rate' => 'Tasa de interacción', + 'link_clicks' => 'Clics en enlaces', + 'outbound_click_rate' => 'Tasa de clics externos', + 'save_rate' => 'Tasa de guardados', + 'follows' => 'Nuevos seguidores', + 'profile_visits' => 'Visitas al perfil', + 'profile_activity' => 'Actividad del perfil', + 'skip_rate' => 'Tasa de omisión', + 'engaged_views' => 'Visualizaciones con interacción', + 'total_audience' => 'Audiencia total', + 'engaged_audience' => 'Audiencia que interactuó', + 'story_navigation' => 'Navegación de historias', + 'story_exits' => 'Salidas de historias', + 'unique_viewers' => 'Espectadores únicos', + ], + 'engagement' => 'Interacción', + 'exposure' => 'Alcance', + 'video' => 'Vídeo y retención', + 'last_collected' => 'Última recopilación', + 'stale' => 'Puede estar desactualizado', + 'awaiting_metrics' => 'Todavía no se han recopilado métricas.', + 'estimated' => 'Estimado', + 'back_to_analytics' => 'Volver a estadísticas', + 'details' => 'Detalles', + 'published_via_trypost' => 'Publicado mediante TryPost', + 'published_on' => 'Publicado en :platform', + ], + 'dashboard' => [ + 'summary' => 'Resumen', + 'posts' => 'Publicaciones', + 'total_followers' => 'Total de seguidores', + 'reactions' => 'Reacciones', + 'comments' => 'Comentarios', + 'engagement_rate' => 'Tasa de interacción', + 'followers' => 'Seguidores', + 'performance' => 'Rendimiento', + 'top_posts' => '5 publicaciones destacadas', + 'channel' => 'Canal', + 'workspace_description' => 'Todos tus canales conectados, con cada cuenta identificada por separado.', + 'latest_snapshot_hint' => 'Las métricas de las publicaciones usan la última medición guardada para las publicaciones de este período.', + 'followers_chart_mode' => 'Tipo de gráfico de seguidores', + 'posts_chart_mode' => 'Tipo de gráfico de publicaciones', + 'chart_line' => 'Línea', + 'chart_bar' => 'Barras', + 'chart_growth' => 'Crecimiento', + 'chart_stacked_bar' => 'Barras apiladas', + 'followers_line_description' => 'Seguidores por cuenta a lo largo del tiempo', + 'no_follower_data' => 'No hay historial de seguidores para este período.', + 'no_post_data' => 'No hay publicaciones en este período.', + 'no_ranked_posts' => 'No hay publicaciones con reacciones o comentarios medidos en este período.', + 'no_performance' => 'No hay datos de rendimiento por canal para este período.', + 'no_excerpt' => 'No hay vista previa de texto disponible.', + 'published_via_trypost' => 'Publicado mediante TryPost', + 'published_on_network' => 'Publicado en la red social', + 'view_post' => 'Ver publicación', + 'top_posts_sort' => 'Ordenar publicaciones por', + 'carried_forward' => 'Último valor conocido', + 'carried_forward_hint' => 'El proveedor no estaba disponible; este es el último número conocido de seguidores.', + 'import_in_progress' => 'Importando el historial de la cuenta. Las publicaciones antiguas aparecerán a medida que avance la recopilación.', + 'no_data_title' => 'Estamos preparando tu historial de estadísticas', + 'no_data_body' => 'Conecta una cuenta compatible o espera a la primera recopilación en segundo plano. Las publicaciones anteriores aparecerán durante la importación.', + ], 'no_accounts' => 'No hay cuentas conectadas con analytics.', 'no_accounts_match' => 'Ninguna cuenta coincide.', 'search_account' => 'Buscar cuenta…', diff --git a/lang/fr/analytics.php b/lang/fr/analytics.php index 465dc8f56..43da830fc 100644 --- a/lang/fr/analytics.php +++ b/lang/fr/analytics.php @@ -1,6 +1,106 @@ 'Statistiques', + 'detail' => [ + 'page_title' => 'Statistiques :platform', + 'content_types' => [ + 'text' => 'Texte', + 'image' => 'Image', + 'carousel' => 'Carrousel', + 'video' => 'Vidéo', + 'reel' => 'Reel', + 'story' => 'Story', + 'short' => 'Short', + 'link' => 'Lien', + 'poll' => 'Sondage', + 'unknown' => 'Inconnu', + ], + 'time_basis' => [ + 'lifetime' => 'Depuis la publication', + 'range' => 'Période sélectionnée', + 'rolling_90_days' => '90 derniers jours', + 'snapshot' => 'Mesure ponctuelle', + ], + 'labels' => [ + 'watch_time_milliseconds' => 'Durée de visionnage', + 'average_watch_time_milliseconds' => 'Durée moyenne de visionnage', + 'total_play_time_milliseconds' => 'Durée totale de lecture', + 'average_video_play_time_milliseconds' => 'Durée moyenne de lecture', + 'average_percentage_viewed' => 'Pourcentage moyen visionné', + 'video_views_10_seconds' => 'Vues de 10 secondes', + 'video_views_95_percent' => 'Vues à 95 %', + 'video_quartile_25' => '25 % visionnés', + 'video_quartile_50' => '50 % visionnés', + 'video_quartile_75' => '75 % visionnés', + 'video_quartile_100' => '100 % visionnés', + 'story_taps_forward' => 'Appuis pour avancer', + 'story_taps_back' => 'Appuis pour revenir', + 'story_swipes_forward' => 'Balayages pour avancer', + 'engagements' => 'Engagements', + 'total_interactions' => 'Interactions totales', + 'engagement_rate' => 'Taux d’engagement', + 'link_clicks' => 'Clics sur les liens', + 'outbound_click_rate' => 'Taux de clics sortants', + 'save_rate' => 'Taux d’enregistrement', + 'follows' => 'Nouveaux abonnements', + 'profile_visits' => 'Visites du profil', + 'profile_activity' => 'Activité du profil', + 'skip_rate' => 'Taux de passage', + 'engaged_views' => 'Vues engagées', + 'total_audience' => 'Audience totale', + 'engaged_audience' => 'Audience engagée', + 'story_navigation' => 'Navigation dans les stories', + 'story_exits' => 'Sorties des stories', + 'unique_viewers' => 'Spectateurs uniques', + ], + 'engagement' => 'Engagement', + 'exposure' => 'Portée', + 'video' => 'Vidéo et rétention', + 'last_collected' => 'Dernière collecte', + 'stale' => 'Peut être obsolète', + 'awaiting_metrics' => 'Les métriques ne sont pas encore disponibles.', + 'estimated' => 'Estimation', + 'back_to_analytics' => 'Retour aux statistiques', + 'details' => 'Détails', + 'published_via_trypost' => 'Publié via TryPost', + 'published_on' => 'Publié sur :platform', + ], + 'dashboard' => [ + 'summary' => 'Résumé', + 'posts' => 'Publications', + 'total_followers' => 'Total des abonnés', + 'reactions' => 'Réactions', + 'comments' => 'Commentaires', + 'engagement_rate' => 'Taux d’engagement', + 'followers' => 'Abonnés', + 'performance' => 'Performances', + 'top_posts' => 'Top 5 des publications', + 'channel' => 'Canal', + 'workspace_description' => 'Tous vos canaux connectés, chaque compte étant identifié séparément.', + 'latest_snapshot_hint' => 'Les métriques des publications utilisent la dernière mesure enregistrée pour les publications de cette période.', + 'followers_chart_mode' => 'Type de graphique des abonnés', + 'posts_chart_mode' => 'Type de graphique des publications', + 'chart_line' => 'Courbe', + 'chart_bar' => 'Barres', + 'chart_growth' => 'Croissance', + 'chart_stacked_bar' => 'Barres empilées', + 'followers_line_description' => 'Abonnés par compte au fil du temps', + 'no_follower_data' => 'Aucun historique des abonnés pour cette période.', + 'no_post_data' => 'Aucune publication pendant cette période.', + 'no_ranked_posts' => 'Aucune publication avec des réactions ou commentaires mesurés pendant cette période.', + 'no_performance' => 'Aucune performance par canal pour cette période.', + 'no_excerpt' => 'Aucun aperçu textuel disponible.', + 'published_via_trypost' => 'Publié via TryPost', + 'published_on_network' => 'Publié sur le réseau social', + 'view_post' => 'Voir la publication', + 'top_posts_sort' => 'Classer les publications par', + 'carried_forward' => 'Dernière valeur connue', + 'carried_forward_hint' => 'Le service était indisponible ; voici le dernier nombre connu d’abonnés.', + 'import_in_progress' => 'Importation de l’historique du compte. Les anciennes publications apparaîtront au fil de la collecte.', + 'no_data_title' => 'Nous préparons votre historique de statistiques', + 'no_data_body' => 'Connectez un compte compatible ou attendez la première collecte en arrière-plan. Les publications historiques apparaîtront pendant l’importation.', + ], 'no_accounts' => 'Aucun compte connecté avec des statistiques.', 'no_accounts_match' => 'Aucun compte correspondant.', 'search_account' => 'Rechercher un compte…', diff --git a/lang/it/analytics.php b/lang/it/analytics.php index b2963aecc..461cd0325 100644 --- a/lang/it/analytics.php +++ b/lang/it/analytics.php @@ -1,6 +1,106 @@ 'Statistiche', + 'detail' => [ + 'page_title' => 'Statistiche di :platform', + 'content_types' => [ + 'text' => 'Testo', + 'image' => 'Immagine', + 'carousel' => 'Carosello', + 'video' => 'Video', + 'reel' => 'Reel', + 'story' => 'Storia', + 'short' => 'Short', + 'link' => 'Link', + 'poll' => 'Sondaggio', + 'unknown' => 'Sconosciuto', + ], + 'time_basis' => [ + 'lifetime' => 'Dalla pubblicazione', + 'range' => 'Periodo selezionato', + 'rolling_90_days' => 'Ultimi 90 giorni', + 'snapshot' => 'Rilevazione puntuale', + ], + 'labels' => [ + 'watch_time_milliseconds' => 'Tempo di visualizzazione', + 'average_watch_time_milliseconds' => 'Tempo medio di visualizzazione', + 'total_play_time_milliseconds' => 'Tempo totale di riproduzione', + 'average_video_play_time_milliseconds' => 'Tempo medio di riproduzione', + 'average_percentage_viewed' => 'Percentuale media visualizzata', + 'video_views_10_seconds' => 'Visualizzazioni di 10 secondi', + 'video_views_95_percent' => 'Visualizzazioni al 95%', + 'video_quartile_25' => '25% visualizzato', + 'video_quartile_50' => '50% visualizzato', + 'video_quartile_75' => '75% visualizzato', + 'video_quartile_100' => '100% visualizzato', + 'story_taps_forward' => 'Tocchi per avanzare', + 'story_taps_back' => 'Tocchi per tornare indietro', + 'story_swipes_forward' => 'Scorrimenti per avanzare', + 'engagements' => 'Interazioni', + 'total_interactions' => 'Interazioni totali', + 'engagement_rate' => 'Tasso di coinvolgimento', + 'link_clicks' => 'Clic sui link', + 'outbound_click_rate' => 'Tasso di clic in uscita', + 'save_rate' => 'Tasso di salvataggio', + 'follows' => 'Nuovi follower', + 'profile_visits' => 'Visite al profilo', + 'profile_activity' => 'Attività del profilo', + 'skip_rate' => 'Tasso di salto', + 'engaged_views' => 'Visualizzazioni coinvolte', + 'total_audience' => 'Pubblico totale', + 'engaged_audience' => 'Pubblico coinvolto', + 'story_navigation' => 'Navigazione delle storie', + 'story_exits' => 'Uscite dalle storie', + 'unique_viewers' => 'Spettatori unici', + ], + 'engagement' => 'Coinvolgimento', + 'exposure' => 'Copertura', + 'video' => 'Video e fidelizzazione', + 'last_collected' => 'Ultima raccolta', + 'stale' => 'Potrebbe non essere aggiornato', + 'awaiting_metrics' => 'Le metriche non sono ancora state raccolte.', + 'estimated' => 'Stima', + 'back_to_analytics' => 'Torna alle statistiche', + 'details' => 'Dettagli', + 'published_via_trypost' => 'Pubblicato tramite TryPost', + 'published_on' => 'Pubblicato su :platform', + ], + 'dashboard' => [ + 'summary' => 'Riepilogo', + 'posts' => 'Post', + 'total_followers' => 'Follower totali', + 'reactions' => 'Reazioni', + 'comments' => 'Commenti', + 'engagement_rate' => 'Tasso di coinvolgimento', + 'followers' => 'Follower', + 'performance' => 'Prestazioni', + 'top_posts' => 'I 5 post migliori', + 'channel' => 'Canale', + 'workspace_description' => 'Tutti i canali collegati, con ogni account identificato separatamente.', + 'latest_snapshot_hint' => 'Le metriche dei post utilizzano l’ultima misurazione salvata per i post pubblicati in questo intervallo.', + 'followers_chart_mode' => 'Tipo di grafico dei follower', + 'posts_chart_mode' => 'Tipo di grafico dei post', + 'chart_line' => 'Linea', + 'chart_bar' => 'Barre', + 'chart_growth' => 'Crescita', + 'chart_stacked_bar' => 'Barre impilate', + 'followers_line_description' => 'Follower per account nel tempo', + 'no_follower_data' => 'La cronologia dei follower non è disponibile per questo periodo.', + 'no_post_data' => 'Nessun post pubblicato in questo periodo.', + 'no_ranked_posts' => 'Nessun post con reazioni o commenti misurati in questo periodo.', + 'no_performance' => 'Nessuna prestazione per canale in questo periodo.', + 'no_excerpt' => 'Anteprima del testo non disponibile.', + 'published_via_trypost' => 'Pubblicato tramite TryPost', + 'published_on_network' => 'Pubblicato sul social network', + 'view_post' => 'Visualizza post', + 'top_posts_sort' => 'Ordina i post per', + 'carried_forward' => 'Ultimo valore noto', + 'carried_forward_hint' => 'Il servizio non era disponibile; questo è l’ultimo numero noto di follower.', + 'import_in_progress' => 'Importazione della cronologia dell’account. I post meno recenti appariranno man mano che procede la raccolta.', + 'no_data_title' => 'Stiamo preparando la cronologia delle statistiche', + 'no_data_body' => 'Collega un account supportato o attendi la prima raccolta in background. I post precedenti appariranno durante l’importazione.', + ], 'no_accounts' => 'Nessun account collegato con statistiche.', 'no_accounts_match' => 'Nessun account corrisponde.', 'search_account' => 'Cerca account…', diff --git a/lang/ja/analytics.php b/lang/ja/analytics.php index cbb1c8588..ae096d0c2 100644 --- a/lang/ja/analytics.php +++ b/lang/ja/analytics.php @@ -1,6 +1,106 @@ 'アナリティクス', + 'detail' => [ + 'page_title' => ':platform の分析', + 'content_types' => [ + 'text' => 'テキスト', + 'image' => '画像', + 'carousel' => 'カルーセル', + 'video' => '動画', + 'reel' => 'リール', + 'story' => 'ストーリーズ', + 'short' => 'ショート', + 'link' => 'リンク', + 'poll' => 'アンケート', + 'unknown' => '不明', + ], + 'time_basis' => [ + 'lifetime' => '公開後の累計', + 'range' => '選択期間', + 'rolling_90_days' => '過去90日間', + 'snapshot' => '時点の値', + ], + 'labels' => [ + 'watch_time_milliseconds' => '視聴時間', + 'average_watch_time_milliseconds' => '平均視聴時間', + 'total_play_time_milliseconds' => '合計再生時間', + 'average_video_play_time_milliseconds' => '平均再生時間', + 'average_percentage_viewed' => '平均視聴率', + 'video_views_10_seconds' => '10秒以上の再生数', + 'video_views_95_percent' => '95%まで再生された回数', + 'video_quartile_25' => '25%視聴', + 'video_quartile_50' => '50%視聴', + 'video_quartile_75' => '75%視聴', + 'video_quartile_100' => '100%視聴', + 'story_taps_forward' => '次へ進むタップ', + 'story_taps_back' => '戻るタップ', + 'story_swipes_forward' => '次へ進むスワイプ', + 'engagements' => 'エンゲージメント', + 'total_interactions' => '合計インタラクション', + 'engagement_rate' => 'エンゲージメント率', + 'link_clicks' => 'リンククリック', + 'outbound_click_rate' => '外部リンククリック率', + 'save_rate' => '保存率', + 'follows' => '新規フォロー', + 'profile_visits' => 'プロフィール訪問', + 'profile_activity' => 'プロフィールでのアクション', + 'skip_rate' => 'スキップ率', + 'engaged_views' => 'エンゲージした再生数', + 'total_audience' => '合計オーディエンス', + 'engaged_audience' => 'エンゲージしたオーディエンス', + 'story_navigation' => 'ストーリーズの操作', + 'story_exits' => 'ストーリーズからの離脱', + 'unique_viewers' => 'ユニーク視聴者', + ], + 'engagement' => 'エンゲージメント', + 'exposure' => 'リーチ', + 'video' => '動画と視聴維持', + 'last_collected' => '最終取得日時', + 'stale' => '最新ではない可能性があります', + 'awaiting_metrics' => '指標はまだ収集されていません。', + 'estimated' => '推定値', + 'back_to_analytics' => '分析に戻る', + 'details' => '詳細', + 'published_via_trypost' => 'TryPostから公開', + 'published_on' => ':platformで公開', + ], + 'dashboard' => [ + 'summary' => '概要', + 'posts' => '投稿', + 'total_followers' => 'フォロワー合計', + 'reactions' => 'リアクション', + 'comments' => 'コメント', + 'engagement_rate' => 'エンゲージメント率', + 'followers' => 'フォロワー', + 'performance' => 'パフォーマンス', + 'top_posts' => '上位5件の投稿', + 'channel' => 'チャンネル', + 'workspace_description' => '接続済みチャンネルをまとめて表示し、各アカウントは個別に区別します。', + 'latest_snapshot_hint' => 'この期間の投稿指標には、保存済みの最新の測定値を使用します。', + 'followers_chart_mode' => 'フォロワーグラフの表示方法', + 'posts_chart_mode' => '投稿グラフの表示方法', + 'chart_line' => '折れ線', + 'chart_bar' => '棒', + 'chart_growth' => '増加', + 'chart_stacked_bar' => '積み上げ棒', + 'followers_line_description' => 'アカウント別フォロワー数の推移', + 'no_follower_data' => 'この期間のフォロワー履歴はありません。', + 'no_post_data' => 'この期間に公開された投稿はありません。', + 'no_ranked_posts' => 'この期間にリアクションやコメントが測定された投稿はありません。', + 'no_performance' => 'この期間のチャンネル別パフォーマンスはありません。', + 'no_excerpt' => 'テキストのプレビューはありません。', + 'published_via_trypost' => 'TryPostから公開', + 'published_on_network' => 'SNS上で公開', + 'view_post' => '投稿を見る', + 'top_posts_sort' => '投稿の並べ替え基準', + 'carried_forward' => '最後に確認された値', + 'carried_forward_hint' => 'サービスに接続できなかったため、最後に確認されたフォロワー数を表示しています。', + 'import_in_progress' => 'アカウント履歴を取り込んでいます。古い投稿は収集が進むにつれて表示されます。', + 'no_data_title' => '分析履歴を準備しています', + 'no_data_body' => '対応するアカウントを接続するか、最初のバックグラウンド収集をお待ちください。過去の投稿は取り込み中に表示されます。', + ], 'no_accounts' => 'アナリティクスを利用できる接続済みアカウントがありません。', 'no_accounts_match' => '一致するアカウントがありません。', 'search_account' => 'アカウントを検索…', diff --git a/lang/ko/analytics.php b/lang/ko/analytics.php index 2001a0388..f7db74eda 100644 --- a/lang/ko/analytics.php +++ b/lang/ko/analytics.php @@ -1,6 +1,106 @@ '분석', + 'detail' => [ + 'page_title' => ':platform 분석', + 'content_types' => [ + 'text' => '텍스트', + 'image' => '이미지', + 'carousel' => '캐러셀', + 'video' => '동영상', + 'reel' => '릴스', + 'story' => '스토리', + 'short' => '쇼츠', + 'link' => '링크', + 'poll' => '투표', + 'unknown' => '알 수 없음', + ], + 'time_basis' => [ + 'lifetime' => '게시 이후 누적', + 'range' => '선택한 기간', + 'rolling_90_days' => '최근 90일', + 'snapshot' => '특정 시점', + ], + 'labels' => [ + 'watch_time_milliseconds' => '시청 시간', + 'average_watch_time_milliseconds' => '평균 시청 시간', + 'total_play_time_milliseconds' => '총 재생 시간', + 'average_video_play_time_milliseconds' => '평균 재생 시간', + 'average_percentage_viewed' => '평균 시청 비율', + 'video_views_10_seconds' => '10초 이상 조회수', + 'video_views_95_percent' => '95% 이상 시청한 조회수', + 'video_quartile_25' => '25% 시청', + 'video_quartile_50' => '50% 시청', + 'video_quartile_75' => '75% 시청', + 'video_quartile_100' => '100% 시청', + 'story_taps_forward' => '앞으로 탭', + 'story_taps_back' => '뒤로 탭', + 'story_swipes_forward' => '앞으로 스와이프', + 'engagements' => '참여', + 'total_interactions' => '총 상호작용', + 'engagement_rate' => '참여율', + 'link_clicks' => '링크 클릭', + 'outbound_click_rate' => '외부 링크 클릭률', + 'save_rate' => '저장률', + 'follows' => '새 팔로우', + 'profile_visits' => '프로필 방문', + 'profile_activity' => '프로필 활동', + 'skip_rate' => '건너뛰기율', + 'engaged_views' => '참여 조회수', + 'total_audience' => '전체 시청자', + 'engaged_audience' => '참여 시청자', + 'story_navigation' => '스토리 탐색', + 'story_exits' => '스토리 이탈', + 'unique_viewers' => '순 시청자', + ], + 'engagement' => '참여', + 'exposure' => '도달', + 'video' => '동영상 및 시청 유지', + 'last_collected' => '마지막 수집', + 'stale' => '최신 데이터가 아닐 수 있음', + 'awaiting_metrics' => '아직 지표가 수집되지 않았습니다.', + 'estimated' => '추정치', + 'back_to_analytics' => '분석으로 돌아가기', + 'details' => '상세 정보', + 'published_via_trypost' => 'TryPost를 통해 게시', + 'published_on' => ':platform에 게시', + ], + 'dashboard' => [ + 'summary' => '요약', + 'posts' => '게시물', + 'total_followers' => '총 팔로워', + 'reactions' => '반응', + 'comments' => '댓글', + 'engagement_rate' => '참여율', + 'followers' => '팔로워', + 'performance' => '성과', + 'top_posts' => '상위 게시물 5개', + 'channel' => '채널', + 'workspace_description' => '연결된 채널을 함께 표시하되 각 계정은 구분합니다.', + 'latest_snapshot_hint' => '이 기간에 게시된 게시물의 지표는 마지막으로 저장된 측정값을 사용합니다.', + 'followers_chart_mode' => '팔로워 차트 표시 방식', + 'posts_chart_mode' => '게시물 차트 표시 방식', + 'chart_line' => '선', + 'chart_bar' => '막대', + 'chart_growth' => '성장', + 'chart_stacked_bar' => '누적 막대', + 'followers_line_description' => '시간에 따른 계정별 팔로워 수', + 'no_follower_data' => '이 기간의 팔로워 기록이 없습니다.', + 'no_post_data' => '이 기간에 게시된 게시물이 없습니다.', + 'no_ranked_posts' => '이 기간에 반응이나 댓글이 측정된 게시물이 없습니다.', + 'no_performance' => '이 기간의 채널별 성과가 없습니다.', + 'no_excerpt' => '텍스트 미리보기가 없습니다.', + 'published_via_trypost' => 'TryPost를 통해 게시', + 'published_on_network' => '소셜 네트워크에 게시', + 'view_post' => '게시물 보기', + 'top_posts_sort' => '게시물 정렬 기준', + 'carried_forward' => '마지막으로 확인된 값', + 'carried_forward_hint' => '서비스를 사용할 수 없어 마지막으로 확인된 팔로워 수를 표시합니다.', + 'import_in_progress' => '계정 기록을 가져오는 중입니다. 수집이 진행되면 이전 게시물이 표시됩니다.', + 'no_data_title' => '분석 기록을 준비하고 있습니다', + 'no_data_body' => '지원되는 계정을 연결하거나 첫 백그라운드 수집을 기다려 주세요. 가져오는 동안 이전 게시물이 표시됩니다.', + ], 'no_accounts' => '분석을 사용할 수 있는 연결된 계정이 없습니다.', 'no_accounts_match' => '일치하는 계정이 없습니다.', 'search_account' => '계정 검색…', diff --git a/lang/nl/analytics.php b/lang/nl/analytics.php index 70e81f62a..876c23840 100644 --- a/lang/nl/analytics.php +++ b/lang/nl/analytics.php @@ -1,6 +1,106 @@ 'Statistieken', + 'detail' => [ + 'page_title' => ':platform-analyses', + 'content_types' => [ + 'text' => 'Tekst', + 'image' => 'Afbeelding', + 'carousel' => 'Carrousel', + 'video' => 'Video', + 'reel' => 'Reel', + 'story' => 'Verhaal', + 'short' => 'Short', + 'link' => 'Link', + 'poll' => 'Peiling', + 'unknown' => 'Onbekend', + ], + 'time_basis' => [ + 'lifetime' => 'Sinds publicatie', + 'range' => 'Geselecteerde periode', + 'rolling_90_days' => 'Laatste 90 dagen', + 'snapshot' => 'Momentopname', + ], + 'labels' => [ + 'watch_time_milliseconds' => 'Kijktijd', + 'average_watch_time_milliseconds' => 'Gemiddelde kijktijd', + 'total_play_time_milliseconds' => 'Totale afspeeltijd', + 'average_video_play_time_milliseconds' => 'Gemiddelde afspeeltijd', + 'average_percentage_viewed' => 'Gemiddeld bekeken percentage', + 'video_views_10_seconds' => 'Weergaven van 10 seconden', + 'video_views_95_percent' => 'Weergaven tot 95%', + 'video_quartile_25' => '25% bekeken', + 'video_quartile_50' => '50% bekeken', + 'video_quartile_75' => '75% bekeken', + 'video_quartile_100' => '100% bekeken', + 'story_taps_forward' => 'Tikacties vooruit', + 'story_taps_back' => 'Tikacties terug', + 'story_swipes_forward' => 'Veegacties vooruit', + 'engagements' => 'Betrokkenheid', + 'total_interactions' => 'Totale interacties', + 'engagement_rate' => 'Betrokkenheidspercentage', + 'link_clicks' => 'Linkklikken', + 'outbound_click_rate' => 'Percentage uitgaande klikken', + 'save_rate' => 'Opslagpercentage', + 'follows' => 'Nieuwe volgers', + 'profile_visits' => 'Profielbezoeken', + 'profile_activity' => 'Profielactiviteit', + 'skip_rate' => 'Overslapercentage', + 'engaged_views' => 'Betrokken weergaven', + 'total_audience' => 'Totaal publiek', + 'engaged_audience' => 'Betrokken publiek', + 'story_navigation' => 'Verhaalnavigatie', + 'story_exits' => 'Verhaalverlatingen', + 'unique_viewers' => 'Unieke kijkers', + ], + 'engagement' => 'Betrokkenheid', + 'exposure' => 'Bereik', + 'video' => 'Video en kijkersbehoud', + 'last_collected' => 'Laatst verzameld', + 'stale' => 'Mogelijk verouderd', + 'awaiting_metrics' => 'Er zijn nog geen statistieken verzameld.', + 'estimated' => 'Geschat', + 'back_to_analytics' => 'Terug naar statistieken', + 'details' => 'Details', + 'published_via_trypost' => 'Gepubliceerd via TryPost', + 'published_on' => 'Gepubliceerd op :platform', + ], + 'dashboard' => [ + 'summary' => 'Overzicht', + 'posts' => 'Berichten', + 'total_followers' => 'Totaal aantal volgers', + 'reactions' => 'Reacties', + 'comments' => 'Opmerkingen', + 'engagement_rate' => 'Betrokkenheidspercentage', + 'followers' => 'Volgers', + 'performance' => 'Prestaties', + 'top_posts' => 'Top 5 berichten', + 'channel' => 'Kanaal', + 'workspace_description' => 'Al je gekoppelde kanalen samen, met elk account afzonderlijk weergegeven.', + 'latest_snapshot_hint' => 'Voor berichten in deze periode wordt de laatst opgeslagen meting gebruikt.', + 'followers_chart_mode' => 'Weergave van de volgersgrafiek', + 'posts_chart_mode' => 'Weergave van de berichtengrafiek', + 'chart_line' => 'Lijn', + 'chart_bar' => 'Balken', + 'chart_growth' => 'Groei', + 'chart_stacked_bar' => 'Gestapelde balken', + 'followers_line_description' => 'Volgers per account in de loop van de tijd', + 'no_follower_data' => 'Er is geen volgersgeschiedenis beschikbaar voor deze periode.', + 'no_post_data' => 'Er zijn geen berichten gepubliceerd in deze periode.', + 'no_ranked_posts' => 'Geen berichten met gemeten reacties of opmerkingen in deze periode.', + 'no_performance' => 'Geen kanaalprestaties beschikbaar voor deze periode.', + 'no_excerpt' => 'Geen tekstvoorbeeld beschikbaar.', + 'published_via_trypost' => 'Gepubliceerd via TryPost', + 'published_on_network' => 'Gepubliceerd op het sociale netwerk', + 'view_post' => 'Bericht bekijken', + 'top_posts_sort' => 'Berichten rangschikken op', + 'carried_forward' => 'Laatst bekende waarde', + 'carried_forward_hint' => 'De aanbieder was niet beschikbaar; dit is het laatst bekende aantal volgers.', + 'import_in_progress' => 'Accountgeschiedenis wordt geïmporteerd. Oudere berichten verschijnen naarmate het verzamelen vordert.', + 'no_data_title' => 'Je statistiekgeschiedenis wordt voorbereid', + 'no_data_body' => 'Koppel een ondersteund account of wacht op de eerste verzameling op de achtergrond. Eerdere berichten verschijnen tijdens de import.', + ], 'no_accounts' => 'Geen gekoppelde accounts met statistieken.', 'no_accounts_match' => 'Geen accounts komen overeen.', 'search_account' => 'Account zoeken…', diff --git a/lang/pl/analytics.php b/lang/pl/analytics.php index eb22d018e..ce0ea06a0 100644 --- a/lang/pl/analytics.php +++ b/lang/pl/analytics.php @@ -1,6 +1,106 @@ 'Analityka', + 'detail' => [ + 'page_title' => 'Analityka :platform', + 'content_types' => [ + 'text' => 'Tekst', + 'image' => 'Obraz', + 'carousel' => 'Karuzela', + 'video' => 'Wideo', + 'reel' => 'Rolka', + 'story' => 'Relacja', + 'short' => 'Short', + 'link' => 'Link', + 'poll' => 'Ankieta', + 'unknown' => 'Nieznany', + ], + 'time_basis' => [ + 'lifetime' => 'Od publikacji', + 'range' => 'Wybrany okres', + 'rolling_90_days' => 'Ostatnie 90 dni', + 'snapshot' => 'Pomiar punktowy', + ], + 'labels' => [ + 'watch_time_milliseconds' => 'Czas oglądania', + 'average_watch_time_milliseconds' => 'Średni czas oglądania', + 'total_play_time_milliseconds' => 'Łączny czas odtwarzania', + 'average_video_play_time_milliseconds' => 'Średni czas odtwarzania', + 'average_percentage_viewed' => 'Średni odsetek obejrzenia', + 'video_views_10_seconds' => 'Wyświetlenia przez 10 sekund', + 'video_views_95_percent' => 'Wyświetlenia do 95%', + 'video_quartile_25' => 'Obejrzano 25%', + 'video_quartile_50' => 'Obejrzano 50%', + 'video_quartile_75' => 'Obejrzano 75%', + 'video_quartile_100' => 'Obejrzano 100%', + 'story_taps_forward' => 'Dotknięcia do przodu', + 'story_taps_back' => 'Dotknięcia wstecz', + 'story_swipes_forward' => 'Przesunięcia do przodu', + 'engagements' => 'Zaangażowania', + 'total_interactions' => 'Łączna liczba interakcji', + 'engagement_rate' => 'Wskaźnik zaangażowania', + 'link_clicks' => 'Kliknięcia linków', + 'outbound_click_rate' => 'Wskaźnik kliknięć wychodzących', + 'save_rate' => 'Wskaźnik zapisów', + 'follows' => 'Nowi obserwujący', + 'profile_visits' => 'Wizyty na profilu', + 'profile_activity' => 'Aktywność profilu', + 'skip_rate' => 'Wskaźnik pominięć', + 'engaged_views' => 'Zaangażowane wyświetlenia', + 'total_audience' => 'Łączna liczba odbiorców', + 'engaged_audience' => 'Zaangażowani odbiorcy', + 'story_navigation' => 'Nawigacja po relacji', + 'story_exits' => 'Wyjścia z relacji', + 'unique_viewers' => 'Unikalni widzowie', + ], + 'engagement' => 'Zaangażowanie', + 'exposure' => 'Zasięg', + 'video' => 'Wideo i utrzymanie uwagi', + 'last_collected' => 'Ostatnio zebrane', + 'stale' => 'Dane mogą być nieaktualne', + 'awaiting_metrics' => 'Metryki nie zostały jeszcze zebrane.', + 'estimated' => 'Szacunkowe', + 'back_to_analytics' => 'Wróć do analityki', + 'details' => 'Szczegóły', + 'published_via_trypost' => 'Opublikowano przez TryPost', + 'published_on' => 'Opublikowano w :platform', + ], + 'dashboard' => [ + 'summary' => 'Podsumowanie', + 'posts' => 'Posty', + 'total_followers' => 'Łączna liczba obserwujących', + 'reactions' => 'Reakcje', + 'comments' => 'Komentarze', + 'engagement_rate' => 'Wskaźnik zaangażowania', + 'followers' => 'Obserwujący', + 'performance' => 'Wyniki', + 'top_posts' => '5 najlepszych postów', + 'channel' => 'Kanał', + 'workspace_description' => 'Wszystkie połączone kanały razem, z każdym kontem pokazanym osobno.', + 'latest_snapshot_hint' => 'Metryki postów korzystają z ostatniego zapisanego pomiaru dla postów opublikowanych w tym okresie.', + 'followers_chart_mode' => 'Widok wykresu obserwujących', + 'posts_chart_mode' => 'Widok wykresu postów', + 'chart_line' => 'Liniowy', + 'chart_bar' => 'Słupki', + 'chart_growth' => 'Wzrost', + 'chart_stacked_bar' => 'Słupki skumulowane', + 'followers_line_description' => 'Obserwujący według konta w czasie', + 'no_follower_data' => 'Historia obserwujących nie jest dostępna dla tego okresu.', + 'no_post_data' => 'Brak opublikowanych postów w tym okresie.', + 'no_ranked_posts' => 'Brak postów z mierzonymi reakcjami lub komentarzami w tym okresie.', + 'no_performance' => 'Brak wyników kanałów w tym okresie.', + 'no_excerpt' => 'Podgląd tekstu jest niedostępny.', + 'published_via_trypost' => 'Opublikowano przez TryPost', + 'published_on_network' => 'Opublikowano w sieci społecznościowej', + 'view_post' => 'Zobacz post', + 'top_posts_sort' => 'Sortuj posty według', + 'carried_forward' => 'Ostatnia znana wartość', + 'carried_forward_hint' => 'Dostawca był niedostępny; to ostatnia znana liczba obserwujących.', + 'import_in_progress' => 'Trwa import historii konta. Starsze posty pojawią się w miarę pobierania.', + 'no_data_title' => 'Przygotowujemy historię Twoich statystyk', + 'no_data_body' => 'Połącz obsługiwane konto lub poczekaj na pierwsze pobranie w tle. Starsze posty pojawią się podczas importu.', + ], 'no_accounts' => 'Brak połączonych kont z analityką.', 'no_accounts_match' => 'Brak pasujących kont.', 'search_account' => 'Szukaj konta…', diff --git a/lang/pt-BR/analytics.php b/lang/pt-BR/analytics.php index 74bd1348e..a91d819dd 100644 --- a/lang/pt-BR/analytics.php +++ b/lang/pt-BR/analytics.php @@ -1,6 +1,106 @@ 'Analytics', + 'detail' => [ + 'page_title' => 'Analytics do :platform', + 'content_types' => [ + 'text' => 'Texto', + 'image' => 'Imagem', + 'carousel' => 'Carrossel', + 'video' => 'Vídeo', + 'reel' => 'Reels', + 'story' => 'Story', + 'short' => 'Short', + 'link' => 'Link', + 'poll' => 'Enquete', + 'unknown' => 'Desconhecido', + ], + 'time_basis' => [ + 'lifetime' => 'Desde a publicação', + 'range' => 'Período selecionado', + 'rolling_90_days' => 'Últimos 90 dias', + 'snapshot' => 'Medição pontual', + ], + 'labels' => [ + 'watch_time_milliseconds' => 'Tempo de exibição', + 'average_watch_time_milliseconds' => 'Tempo médio de exibição', + 'total_play_time_milliseconds' => 'Tempo total de reprodução', + 'average_video_play_time_milliseconds' => 'Tempo médio de reprodução', + 'average_percentage_viewed' => 'Percentual médio assistido', + 'video_views_10_seconds' => 'Visualizações de 10 segundos', + 'video_views_95_percent' => 'Visualizações de 95%', + 'video_quartile_25' => '25% assistido', + 'video_quartile_50' => '50% assistido', + 'video_quartile_75' => '75% assistido', + 'video_quartile_100' => '100% assistido', + 'story_taps_forward' => 'Toques para avançar', + 'story_taps_back' => 'Toques para voltar', + 'story_swipes_forward' => 'Deslizes para avançar', + 'engagements' => 'Engajamentos', + 'total_interactions' => 'Total de interações', + 'engagement_rate' => 'Taxa de engajamento', + 'link_clicks' => 'Cliques no link', + 'outbound_click_rate' => 'Taxa de cliques externos', + 'save_rate' => 'Taxa de salvamentos', + 'follows' => 'Novos seguidores', + 'profile_visits' => 'Visitas ao perfil', + 'profile_activity' => 'Atividade no perfil', + 'skip_rate' => 'Taxa de pulos', + 'engaged_views' => 'Visualizações engajadas', + 'total_audience' => 'Público total', + 'engaged_audience' => 'Público engajado', + 'story_navigation' => 'Navegação do story', + 'story_exits' => 'Saídas do story', + 'unique_viewers' => 'Espectadores únicos', + ], + 'engagement' => 'Engajamento', + 'exposure' => 'Exposição', + 'video' => 'Vídeo e retenção', + 'last_collected' => 'Última coleta', + 'stale' => 'Pode estar desatualizado', + 'awaiting_metrics' => 'As métricas ainda não foram coletadas.', + 'estimated' => 'Estimado', + 'back_to_analytics' => 'Voltar para analytics', + 'details' => 'Detalhes', + 'published_via_trypost' => 'Publicado pelo TryPost', + 'published_on' => 'Publicado no :platform', + ], + 'dashboard' => [ + 'summary' => 'Resumo', + 'posts' => 'Posts', + 'total_followers' => 'Total de seguidores', + 'reactions' => 'Reações', + 'comments' => 'Comentários', + 'engagement_rate' => 'Taxa de engajamento', + 'followers' => 'Seguidores', + 'performance' => 'Desempenho', + 'top_posts' => 'Top 5 posts', + 'channel' => 'Canal', + 'workspace_description' => 'Todas as redes do workspace, com cada conta identificada separadamente.', + 'latest_snapshot_hint' => 'As métricas dos posts usam a última medição salva para posts publicados neste período.', + 'followers_chart_mode' => 'Visualização dos seguidores', + 'posts_chart_mode' => 'Visualização dos posts', + 'chart_line' => 'Linha', + 'chart_bar' => 'Barras', + 'chart_growth' => 'Crescimento', + 'chart_stacked_bar' => 'Barras empilhadas', + 'followers_line_description' => 'Seguidores por conta ao longo do tempo', + 'no_follower_data' => 'Não há histórico de seguidores neste período.', + 'no_post_data' => 'Não há posts publicados neste período.', + 'no_ranked_posts' => 'Não há posts com reações ou comentários medidos neste período.', + 'no_performance' => 'Não há desempenho por canal neste período.', + 'no_excerpt' => 'Prévia de texto indisponível.', + 'published_via_trypost' => 'Publicado pelo TryPost', + 'published_on_network' => 'Publicado na rede social', + 'view_post' => 'Ver post', + 'top_posts_sort' => 'Ordenar posts por', + 'carried_forward' => 'Último valor', + 'carried_forward_hint' => 'A rede ficou indisponível; este é o último número conhecido de seguidores.', + 'import_in_progress' => 'Importando o histórico das contas. Posts antigos aparecerão conforme a coleta avançar.', + 'no_data_title' => 'Estamos preparando seu histórico de analytics', + 'no_data_body' => 'Conecte uma conta compatível ou aguarde a primeira coleta em segundo plano. Os posts históricos aparecerão durante a importação.', + ], 'no_accounts' => 'Nenhuma conta conectada com analytics.', 'no_accounts_match' => 'Nenhuma conta corresponde.', 'search_account' => 'Buscar conta…', diff --git a/lang/ru/analytics.php b/lang/ru/analytics.php index 0abb9aae6..c196b4c14 100644 --- a/lang/ru/analytics.php +++ b/lang/ru/analytics.php @@ -1,6 +1,106 @@ 'Аналитика', + 'detail' => [ + 'page_title' => 'Аналитика :platform', + 'content_types' => [ + 'text' => 'Текст', + 'image' => 'Изображение', + 'carousel' => 'Карусель', + 'video' => 'Видео', + 'reel' => 'Рилс', + 'story' => 'История', + 'short' => 'Shorts', + 'link' => 'Ссылка', + 'poll' => 'Опрос', + 'unknown' => 'Неизвестно', + ], + 'time_basis' => [ + 'lifetime' => 'За всё время', + 'range' => 'Выбранный период', + 'rolling_90_days' => 'Последние 90 дней', + 'snapshot' => 'Снимок данных', + ], + 'labels' => [ + 'watch_time_milliseconds' => 'Время просмотра', + 'average_watch_time_milliseconds' => 'Среднее время просмотра', + 'total_play_time_milliseconds' => 'Общее время воспроизведения', + 'average_video_play_time_milliseconds' => 'Среднее время воспроизведения', + 'average_percentage_viewed' => 'Средний процент просмотра', + 'video_views_10_seconds' => 'Просмотры от 10 секунд', + 'video_views_95_percent' => 'Просмотры до 95%', + 'video_quartile_25' => 'Просмотрено 25%', + 'video_quartile_50' => 'Просмотрено 50%', + 'video_quartile_75' => 'Просмотрено 75%', + 'video_quartile_100' => 'Просмотрено 100%', + 'story_taps_forward' => 'Нажатия вперёд', + 'story_taps_back' => 'Нажатия назад', + 'story_swipes_forward' => 'Смахивания вперёд', + 'engagements' => 'Вовлечения', + 'total_interactions' => 'Всего взаимодействий', + 'engagement_rate' => 'Уровень вовлечённости', + 'link_clicks' => 'Переходы по ссылкам', + 'outbound_click_rate' => 'Доля внешних переходов', + 'save_rate' => 'Доля сохранений', + 'follows' => 'Новые подписчики', + 'profile_visits' => 'Посещения профиля', + 'profile_activity' => 'Действия в профиле', + 'skip_rate' => 'Доля пропусков', + 'engaged_views' => 'Вовлечённые просмотры', + 'total_audience' => 'Общая аудитория', + 'engaged_audience' => 'Вовлечённая аудитория', + 'story_navigation' => 'Навигация по историям', + 'story_exits' => 'Выходы из историй', + 'unique_viewers' => 'Уникальные зрители', + ], + 'engagement' => 'Вовлечённость', + 'exposure' => 'Охват', + 'video' => 'Видео и удержание аудитории', + 'last_collected' => 'Последний сбор данных', + 'stale' => 'Данные могут быть устаревшими', + 'awaiting_metrics' => 'Показатели ещё не собраны.', + 'estimated' => 'Оценка', + 'back_to_analytics' => 'Вернуться к аналитике', + 'details' => 'Подробности', + 'published_via_trypost' => 'Опубликовано через TryPost', + 'published_on' => 'Опубликовано в :platform', + ], + 'dashboard' => [ + 'summary' => 'Сводка', + 'posts' => 'Публикации', + 'total_followers' => 'Всего подписчиков', + 'reactions' => 'Реакции', + 'comments' => 'Комментарии', + 'engagement_rate' => 'Уровень вовлечённости', + 'followers' => 'Подписчики', + 'performance' => 'Результаты', + 'top_posts' => '5 лучших публикаций', + 'channel' => 'Канал', + 'workspace_description' => 'Все подключённые каналы вместе, при этом каждый аккаунт показан отдельно.', + 'latest_snapshot_hint' => 'Для публикаций за этот период используются последние сохранённые показатели.', + 'followers_chart_mode' => 'Режим графика подписчиков', + 'posts_chart_mode' => 'Режим графика публикаций', + 'chart_line' => 'Линия', + 'chart_bar' => 'Столбцы', + 'chart_growth' => 'Рост', + 'chart_stacked_bar' => 'Столбцы с накоплением', + 'followers_line_description' => 'Число подписчиков по аккаунтам с течением времени', + 'no_follower_data' => 'За этот период нет истории подписчиков.', + 'no_post_data' => 'За этот период нет опубликованных материалов.', + 'no_ranked_posts' => 'За этот период нет публикаций с измеренными реакциями или комментариями.', + 'no_performance' => 'За этот период нет данных о результатах каналов.', + 'no_excerpt' => 'Текстовое превью недоступно.', + 'published_via_trypost' => 'Опубликовано через TryPost', + 'published_on_network' => 'Опубликовано в социальной сети', + 'view_post' => 'Посмотреть публикацию', + 'top_posts_sort' => 'Сортировать публикации по', + 'carried_forward' => 'Последнее известное значение', + 'carried_forward_hint' => 'Сервис был недоступен; показано последнее известное число подписчиков.', + 'import_in_progress' => 'Импортируем историю аккаунта. Более старые публикации появятся по мере сбора данных.', + 'no_data_title' => 'Подготавливаем историю аналитики', + 'no_data_body' => 'Подключите поддерживаемый аккаунт или дождитесь первого фонового сбора данных. Исторические публикации появятся во время импорта.', + ], 'no_accounts' => 'Нет подключённых аккаунтов с аналитикой.', 'no_accounts_match' => 'Нет подходящих аккаунтов.', 'search_account' => 'Поиск аккаунта…', diff --git a/lang/tr/analytics.php b/lang/tr/analytics.php index 15ff9f09c..869fa8ec7 100644 --- a/lang/tr/analytics.php +++ b/lang/tr/analytics.php @@ -3,6 +3,106 @@ declare(strict_types=1); return [ + 'title' => 'Analitik', + 'detail' => [ + 'page_title' => ':platform analizleri', + 'content_types' => [ + 'text' => 'Metin', + 'image' => 'Görsel', + 'carousel' => 'Karusel', + 'video' => 'Video', + 'reel' => 'Reel', + 'story' => 'Hikâye', + 'short' => 'Short', + 'link' => 'Bağlantı', + 'poll' => 'Anket', + 'unknown' => 'Bilinmiyor', + ], + 'time_basis' => [ + 'lifetime' => 'Yayınlandığından beri', + 'range' => 'Seçilen dönem', + 'rolling_90_days' => 'Son 90 gün', + 'snapshot' => 'Anlık ölçüm', + ], + 'labels' => [ + 'watch_time_milliseconds' => 'İzlenme süresi', + 'average_watch_time_milliseconds' => 'Ortalama izlenme süresi', + 'total_play_time_milliseconds' => 'Toplam oynatma süresi', + 'average_video_play_time_milliseconds' => 'Ortalama oynatma süresi', + 'average_percentage_viewed' => 'Ortalama izlenme yüzdesi', + 'video_views_10_seconds' => '10 saniyelik görüntülemeler', + 'video_views_95_percent' => '%95 düzeyine ulaşan görüntülemeler', + 'video_quartile_25' => '%25 izlendi', + 'video_quartile_50' => '%50 izlendi', + 'video_quartile_75' => '%75 izlendi', + 'video_quartile_100' => '%100 izlendi', + 'story_taps_forward' => 'İleri dokunmalar', + 'story_taps_back' => 'Geri dokunmalar', + 'story_swipes_forward' => 'İleri kaydırmalar', + 'engagements' => 'Etkileşimler', + 'total_interactions' => 'Toplam etkileşim', + 'engagement_rate' => 'Etkileşim oranı', + 'link_clicks' => 'Bağlantı tıklamaları', + 'outbound_click_rate' => 'Dış bağlantı tıklama oranı', + 'save_rate' => 'Kaydetme oranı', + 'follows' => 'Yeni takipçiler', + 'profile_visits' => 'Profil ziyaretleri', + 'profile_activity' => 'Profil etkinliği', + 'skip_rate' => 'Atlama oranı', + 'engaged_views' => 'Etkileşimli görüntülemeler', + 'total_audience' => 'Toplam kitle', + 'engaged_audience' => 'Etkileşimli kitle', + 'story_navigation' => 'Hikâye gezintisi', + 'story_exits' => 'Hikâyeden çıkışlar', + 'unique_viewers' => 'Benzersiz izleyiciler', + ], + 'engagement' => 'Etkileşim', + 'exposure' => 'Erişim', + 'video' => 'Video ve izleyici tutma', + 'last_collected' => 'Son veri toplama', + 'stale' => 'Veriler güncel olmayabilir', + 'awaiting_metrics' => 'Metrikler henüz toplanmadı.', + 'estimated' => 'Tahmini', + 'back_to_analytics' => 'Analizlere dön', + 'details' => 'Ayrıntılar', + 'published_via_trypost' => 'TryPost aracılığıyla yayınlandı', + 'published_on' => ':platform üzerinde yayınlandı', + ], + 'dashboard' => [ + 'summary' => 'Özet', + 'posts' => 'Gönderiler', + 'total_followers' => 'Toplam takipçi', + 'reactions' => 'Tepkiler', + 'comments' => 'Yorumlar', + 'engagement_rate' => 'Etkileşim oranı', + 'followers' => 'Takipçiler', + 'performance' => 'Performans', + 'top_posts' => 'En iyi 5 gönderi', + 'channel' => 'Kanal', + 'workspace_description' => 'Bağlı kanallarınız bir arada, her hesap ayrı gösterilir.', + 'latest_snapshot_hint' => 'Bu dönemde yayınlanan gönderiler için son kaydedilen ölçümler kullanılır.', + 'followers_chart_mode' => 'Takipçi grafiği modu', + 'posts_chart_mode' => 'Gönderi grafiği modu', + 'chart_line' => 'Çizgi', + 'chart_bar' => 'Çubuk', + 'chart_growth' => 'Büyüme', + 'chart_stacked_bar' => 'Yığılmış çubuk', + 'followers_line_description' => 'Zaman içinde hesaplara göre takipçiler', + 'no_follower_data' => 'Bu dönem için takipçi geçmişi mevcut değil.', + 'no_post_data' => 'Bu dönemde yayınlanan gönderi yok.', + 'no_ranked_posts' => 'Bu dönemde ölçülmüş tepki veya yorumu olan gönderi yok.', + 'no_performance' => 'Bu dönem için kanal performansı mevcut değil.', + 'no_excerpt' => 'Metin önizlemesi mevcut değil.', + 'published_via_trypost' => 'TryPost aracılığıyla yayınlandı', + 'published_on_network' => 'Sosyal ağda yayınlandı', + 'view_post' => 'Gönderiyi görüntüle', + 'top_posts_sort' => 'Gönderileri şuna göre sırala', + 'carried_forward' => 'Bilinen son değer', + 'carried_forward_hint' => 'Sağlayıcı kullanılamıyordu; bu, bilinen son takipçi sayısıdır.', + 'import_in_progress' => 'Hesap geçmişi içe aktarılıyor. Veri toplama ilerledikçe eski gönderiler görünecek.', + 'no_data_title' => 'Analiz geçmişiniz hazırlanıyor', + 'no_data_body' => 'Desteklenen bir hesap bağlayın veya arka plandaki ilk veri toplamayı bekleyin. Geçmiş gönderiler içe aktarma sırasında görünecek.', + ], 'no_accounts' => 'Analitiği olan bağlı hesap yok.', 'no_accounts_match' => 'Eşleşen hesap yok.', 'search_account' => 'Hesap ara…', diff --git a/lang/uk/analytics.php b/lang/uk/analytics.php index 615409b4d..8c8b0ad19 100644 --- a/lang/uk/analytics.php +++ b/lang/uk/analytics.php @@ -1,6 +1,106 @@ 'Аналітика', + 'detail' => [ + 'page_title' => 'Аналітика :platform', + 'content_types' => [ + 'text' => 'Текст', + 'image' => 'Зображення', + 'carousel' => 'Карусель', + 'video' => 'Відео', + 'reel' => 'Рілс', + 'story' => 'Історія', + 'short' => 'Shorts', + 'link' => 'Посилання', + 'poll' => 'Опитування', + 'unknown' => 'Невідомо', + ], + 'time_basis' => [ + 'lifetime' => 'Від публікації', + 'range' => 'Вибраний період', + 'rolling_90_days' => 'Останні 90 днів', + 'snapshot' => 'Знімок даних', + ], + 'labels' => [ + 'watch_time_milliseconds' => 'Час перегляду', + 'average_watch_time_milliseconds' => 'Середній час перегляду', + 'total_play_time_milliseconds' => 'Загальний час відтворення', + 'average_video_play_time_milliseconds' => 'Середній час відтворення', + 'average_percentage_viewed' => 'Середній відсоток перегляду', + 'video_views_10_seconds' => 'Перегляди від 10 секунд', + 'video_views_95_percent' => 'Перегляди до 95%', + 'video_quartile_25' => 'Переглянуто 25%', + 'video_quartile_50' => 'Переглянуто 50%', + 'video_quartile_75' => 'Переглянуто 75%', + 'video_quartile_100' => 'Переглянуто 100%', + 'story_taps_forward' => 'Натискання вперед', + 'story_taps_back' => 'Натискання назад', + 'story_swipes_forward' => 'Гортання вперед', + 'engagements' => 'Взаємодії', + 'total_interactions' => 'Усього взаємодій', + 'engagement_rate' => 'Рівень залученості', + 'link_clicks' => 'Переходи за посиланнями', + 'outbound_click_rate' => 'Частка зовнішніх переходів', + 'save_rate' => 'Частка збережень', + 'follows' => 'Нові підписники', + 'profile_visits' => 'Відвідування профілю', + 'profile_activity' => 'Дії в профілі', + 'skip_rate' => 'Частка пропусків', + 'engaged_views' => 'Залучені перегляди', + 'total_audience' => 'Загальна аудиторія', + 'engaged_audience' => 'Залучена аудиторія', + 'story_navigation' => 'Навігація в історіях', + 'story_exits' => 'Виходи з історій', + 'unique_viewers' => 'Унікальні глядачі', + ], + 'engagement' => 'Залученість', + 'exposure' => 'Охоплення', + 'video' => 'Відео й утримання уваги', + 'last_collected' => 'Останнє збирання', + 'stale' => 'Дані можуть бути застарілими', + 'awaiting_metrics' => 'Показники ще не зібрано.', + 'estimated' => 'Оцінка', + 'back_to_analytics' => 'Повернутися до аналітики', + 'details' => 'Деталі', + 'published_via_trypost' => 'Опубліковано через TryPost', + 'published_on' => 'Опубліковано в :platform', + ], + 'dashboard' => [ + 'summary' => 'Підсумок', + 'posts' => 'Дописи', + 'total_followers' => 'Усього підписників', + 'reactions' => 'Реакції', + 'comments' => 'Коментарі', + 'engagement_rate' => 'Рівень залученості', + 'followers' => 'Підписники', + 'performance' => 'Результати', + 'top_posts' => '5 найкращих дописів', + 'channel' => 'Канал', + 'workspace_description' => 'Усі підключені канали разом, але кожен обліковий запис відображається окремо.', + 'latest_snapshot_hint' => 'Для дописів за цей період використовуються останні збережені показники.', + 'followers_chart_mode' => 'Режим графіка підписників', + 'posts_chart_mode' => 'Режим графіка дописів', + 'chart_line' => 'Лінія', + 'chart_bar' => 'Стовпчики', + 'chart_growth' => 'Зростання', + 'chart_stacked_bar' => 'Стовпчики з накопиченням', + 'followers_line_description' => 'Кількість підписників за обліковими записами з часом', + 'no_follower_data' => 'За цей період немає історії підписників.', + 'no_post_data' => 'За цей період немає опублікованих дописів.', + 'no_ranked_posts' => 'За цей період немає дописів із виміряними реакціями чи коментарями.', + 'no_performance' => 'За цей період немає даних про результати каналів.', + 'no_excerpt' => 'Попередній перегляд тексту недоступний.', + 'published_via_trypost' => 'Опубліковано через TryPost', + 'published_on_network' => 'Опубліковано в соціальній мережі', + 'view_post' => 'Переглянути допис', + 'top_posts_sort' => 'Сортувати дописи за', + 'carried_forward' => 'Останнє відоме значення', + 'carried_forward_hint' => 'Сервіс був недоступний; показано останню відому кількість підписників.', + 'import_in_progress' => 'Імпортуємо історію облікового запису. Старіші дописи з’являться в міру збирання даних.', + 'no_data_title' => 'Готуємо історію аналітики', + 'no_data_body' => 'Підключіть підтримуваний обліковий запис або дочекайтеся першого фонового збирання даних. Історичні дописи з’являться під час імпорту.', + ], 'no_accounts' => 'Немає підключених акаунтів з аналітикою.', 'no_accounts_match' => 'Жоден акаунт не відповідає.', 'search_account' => 'Пошук акаунта…', diff --git a/lang/zh/analytics.php b/lang/zh/analytics.php index f8840f2d1..e6cf997e1 100644 --- a/lang/zh/analytics.php +++ b/lang/zh/analytics.php @@ -1,6 +1,106 @@ '分析', + 'detail' => [ + 'page_title' => ':platform 分析', + 'content_types' => [ + 'text' => '文字', + 'image' => '图片', + 'carousel' => '轮播', + 'video' => '视频', + 'reel' => '短视频', + 'story' => '快拍', + 'short' => 'Shorts', + 'link' => '链接', + 'poll' => '投票', + 'unknown' => '未知', + ], + 'time_basis' => [ + 'lifetime' => '发布以来', + 'range' => '所选时间段', + 'rolling_90_days' => '最近90天', + 'snapshot' => '单次快照', + ], + 'labels' => [ + 'watch_time_milliseconds' => '观看时长', + 'average_watch_time_milliseconds' => '平均观看时长', + 'total_play_time_milliseconds' => '总播放时长', + 'average_video_play_time_milliseconds' => '平均播放时长', + 'average_percentage_viewed' => '平均观看百分比', + 'video_views_10_seconds' => '观看超过10秒的次数', + 'video_views_95_percent' => '观看至95%的次数', + 'video_quartile_25' => '已观看25%', + 'video_quartile_50' => '已观看50%', + 'video_quartile_75' => '已观看75%', + 'video_quartile_100' => '已观看100%', + 'story_taps_forward' => '点击前进次数', + 'story_taps_back' => '点击返回次数', + 'story_swipes_forward' => '滑动前进次数', + 'engagements' => '互动量', + 'total_interactions' => '总互动量', + 'engagement_rate' => '互动率', + 'link_clicks' => '链接点击量', + 'outbound_click_rate' => '外链点击率', + 'save_rate' => '保存率', + 'follows' => '新增关注', + 'profile_visits' => '个人主页访问量', + 'profile_activity' => '个人主页活动量', + 'skip_rate' => '跳过率', + 'engaged_views' => '有效观看量', + 'total_audience' => '总受众', + 'engaged_audience' => '互动受众', + 'story_navigation' => '快拍导航', + 'story_exits' => '退出快拍次数', + 'unique_viewers' => '独立观众数', + ], + 'engagement' => '互动', + 'exposure' => '触达', + 'video' => '视频与观看留存', + 'last_collected' => '上次采集', + 'stale' => '数据可能已过时', + 'awaiting_metrics' => '尚未采集到指标。', + 'estimated' => '估算值', + 'back_to_analytics' => '返回分析', + 'details' => '详情', + 'published_via_trypost' => '通过TryPost发布', + 'published_on' => '发布于:platform', + ], + 'dashboard' => [ + 'summary' => '概览', + 'posts' => '帖子', + 'total_followers' => '粉丝总数', + 'reactions' => '回应', + 'comments' => '评论', + 'engagement_rate' => '互动率', + 'followers' => '粉丝', + 'performance' => '表现', + 'top_posts' => '前5条帖子', + 'channel' => '渠道', + 'workspace_description' => '汇总已连接的渠道,同时分别显示每个账号。', + 'latest_snapshot_hint' => '此时间段内的帖子指标使用最近一次保存的测量值。', + 'followers_chart_mode' => '粉丝图表模式', + 'posts_chart_mode' => '帖子图表模式', + 'chart_line' => '折线', + 'chart_bar' => '柱状', + 'chart_growth' => '增长', + 'chart_stacked_bar' => '堆叠柱状', + 'followers_line_description' => '各账号粉丝数随时间的变化', + 'no_follower_data' => '此时间段没有粉丝历史数据。', + 'no_post_data' => '此时间段没有已发布的帖子。', + 'no_ranked_posts' => '此时间段没有测得回应或评论的帖子。', + 'no_performance' => '此时间段没有渠道表现数据。', + 'no_excerpt' => '没有可用的文字预览。', + 'published_via_trypost' => '通过TryPost发布', + 'published_on_network' => '在社交平台发布', + 'view_post' => '查看帖子', + 'top_posts_sort' => '帖子排序依据', + 'carried_forward' => '最近已知值', + 'carried_forward_hint' => '服务暂时不可用;这里显示最近已知的粉丝数。', + 'import_in_progress' => '正在导入账号历史记录。采集过程中会逐步显示更早的帖子。', + 'no_data_title' => '正在准备你的分析历史数据', + 'no_data_body' => '连接受支持的账号,或等待首次后台采集。导入过程中会显示历史帖子。', + ], 'no_accounts' => '没有可查看分析数据的已连接账号。', 'no_accounts_match' => '没有匹配的账号。', 'search_account' => '搜索账号…', diff --git a/package-lock.json b/package-lock.json index b86ad083d..e91eed253 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "@inertiajs/vue3": "^3.6.1", "@tabler/icons-vue": "^3.36.1", "@tailwindcss/typography": "^0.5.19", + "@unovis/ts": "^1.7.0", + "@unovis/vue": "^1.7.0", "@vueuse/core": "^12.8.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -61,31 +63,83 @@ "lightningcss-win32-x64-msvc": "^1.29.1" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -94,14 +148,55 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -138,6 +233,100 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/css": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz", + "integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==", + "license": "MIT", + "dependencies": { + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -486,7 +675,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -529,6 +717,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, "node_modules/@laravel/echo-vue": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@laravel/echo-vue/-/echo-vue-2.3.0.tgz", @@ -551,6 +745,92 @@ "dev": true, "license": "MIT" }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.1.tgz", + "integrity": "sha512-I/qcIKVFHFSg1Meu/eqHrkSTjIez0gCsSaK56TNPutM3TkE61aSq6F1cZ4Kg4KiNs3cpViLtubDjfyRcQCdEJQ==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.3", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz", + "integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1609,6 +1889,295 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-collection": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@types/d3-collection/-/d3-collection-1.0.13.tgz", + "integrity": "sha512-v0Rgw3IZebRyamcwVmtTDCZ8OmQcj4siaYjNc7wGMZT7PmdSHawGsCOQMxyLvZ7lWjfohYLK0oXtilMOMgfY8A==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.5.tgz", + "integrity": "sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==", + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1616,6 +2185,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1630,6 +2205,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.7.6.tgz", + "integrity": "sha512-Emkz3V08QnlelSbpT46OEAx+TBZYTOX2r1yM7W+hWg5+djHtQ1GbEXBDRLaqQDOYcDI51Ss0ayoqoKD4CtLUDA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "22.19.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz", @@ -1639,37 +2223,116 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/supercluster": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-5.0.3.tgz", + "integrity": "sha512-XMSqQEr7YDuNtFwSgaHHOjsbi0ZGL62V9Js4CW45RBuRYlNWSW/KDqN+RFFE7HdHcGhJPtN0klKvw06r9Kg7rg==", "license": "MIT", - "optional": true + "dependencies": { + "@types/geojson": "*" + } }, - "node_modules/@types/web-bluetooth": { - "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "node_modules/@types/three": { + "version": "0.135.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.135.0.tgz", + "integrity": "sha512-l7WLhIHjhHMtlpyTSltPPAKLpiMwgMD1hXHj59AVUpYRoZP7Fd9NNOSRSvZBCPLpTHPYojgQvSJCoza9zoL7bg==", "license": "MIT" }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", - "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", - "dev": true, + "node_modules/@types/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==", + "license": "MIT" + }, + "node_modules/@types/topojson": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@types/topojson/-/topojson-3.2.6.tgz", + "integrity": "sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==", "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.53.0", - "@typescript-eslint/type-utils": "8.53.0", - "@typescript-eslint/utils": "8.53.0", - "@typescript-eslint/visitor-keys": "8.53.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@types/geojson": "*", + "@types/topojson-client": "*", + "@types/topojson-server": "*", + "@types/topojson-simplify": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-client": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz", + "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-server": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/topojson-server/-/topojson-server-3.0.4.tgz", + "integrity": "sha512-5+ieK8ePfP+K2VH6Vgs1VCt+fO1U8XZHj0UsF+NktaF0DavAo1q3IvCBXgokk/xmtvoPltSUs6vxuR/zMdOE1g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-simplify": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/topojson-simplify/-/topojson-simplify-3.0.3.tgz", + "integrity": "sha512-sBO5UZ0O2dB0bNwo0vut2yLHhj3neUGi9uL7/ROdm8Gs6dtt4jcB9OGDKr+M2isZwQM2RuzVmifnMZpxj4IGNw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-specification": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz", + "integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", + "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/type-utils": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -1895,6 +2558,112 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@unovis/dagre-layout": { + "version": "0.8.8-3", + "resolved": "https://registry.npmjs.org/@unovis/dagre-layout/-/dagre-layout-0.8.8-3.tgz", + "integrity": "sha512-RAL2fhQN1acO5QS82oe/yz0npSe80nDYWbxDpcTKWmb+4x2/WMwmgr8ScdiySkwmK7oDvEN72RIvzJkx5A3pUA==", + "license": "MIT", + "dependencies": { + "@unovis/graphlibrary": "2.2.0-3", + "lodash-es": "^4.18.1" + } + }, + "node_modules/@unovis/graphlibrary": { + "version": "2.2.0-3", + "resolved": "https://registry.npmjs.org/@unovis/graphlibrary/-/graphlibrary-2.2.0-3.tgz", + "integrity": "sha512-ReEjk/cDih+q+z0U0EluHpPWlfj31v+nIglmfCNUuNuTv5FtGOKTYHbUSISX6SxHR1RZMLfP/kNniPr0H9KRIw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.18.1" + } + }, + "node_modules/@unovis/ts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@unovis/ts/-/ts-1.7.0.tgz", + "integrity": "sha512-aEilfV47FNsU8VarSUjUUekhlt/a1dP4XjTLEEPSMgIVHUsyX+xMGFfgXr1r9eAv6NOJIIeTlJiEYz2LOOiUWw==", + "license": "Apache-2.0", + "dependencies": { + "@emotion/css": "^11.7.1", + "@juggle/resize-observer": "^3.3.1", + "@maplibre/maplibre-gl-style-spec": "26.4.1", + "@types/d3": "^7.4.0", + "@types/d3-array": "~3.2.2", + "@types/d3-axis": "~3.0.6", + "@types/d3-brush": "~3.0.6", + "@types/d3-chord": "~3.0.6", + "@types/d3-collection": "^1.0.10", + "@types/d3-color": "~3.1.3", + "@types/d3-drag": "~3.0.7", + "@types/d3-ease": "~3.0.2", + "@types/d3-force": "~3.0.10", + "@types/d3-geo": "~3.1.0", + "@types/d3-hierarchy": "~3.1.7", + "@types/d3-interpolate": "~3.0.4", + "@types/d3-path": "~3.1.1", + "@types/d3-sankey": "^0.12.4", + "@types/d3-scale": "~4.0.9", + "@types/d3-selection": "~3.0.0", + "@types/d3-shape": "~3.1.7", + "@types/d3-timer": "~3.0.2", + "@types/d3-transition": "~3.0.9", + "@types/d3-zoom": "~3.0.8", + "@types/dagre": "^0.7.50", + "@types/geojson": "^7946.0.8", + "@types/leaflet": "1.7.6", + "@types/supercluster": "^5.0.2", + "@types/three": "^0.135.0", + "@types/throttle-debounce": "^5.0.0", + "@types/topojson": "^3.2.3", + "@types/topojson-client": "^3.0.0", + "@types/topojson-specification": "^1.0.2", + "@unovis/dagre-layout": "0.8.8-3", + "@unovis/graphlibrary": "2.2.0-3", + "csstype": "^3.2.3", + "d3": "^7.2.1", + "d3-array": "~3", + "d3-axis": "~3", + "d3-brush": "~3", + "d3-chord": "~3", + "d3-collection": "^1.0.7", + "d3-color": "~3", + "d3-drag": "~3", + "d3-ease": "~3", + "d3-force": "~3", + "d3-geo": "~3", + "d3-geo-projection": "^4.0.0", + "d3-hierarchy": "~3", + "d3-interpolate": "~3", + "d3-interpolate-path": "^2.2.3", + "d3-path": "~3", + "d3-sankey": "^0.12.3", + "d3-scale": "~4", + "d3-selection": "~3", + "d3-shape": "~3", + "d3-timer": "~3", + "d3-transition": "~3", + "d3-zoom": "~3", + "elkjs": "^0.10.0", + "geojson": "^0.5.0", + "leaflet": "1.7.1", + "maplibre-gl": "6.7.0", + "striptags": "^3.2.0", + "supercluster": "^7.1.5", + "three": "^0.135.0", + "throttle-debounce": "^5.0.0", + "topojson-client": "^3.1.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@unovis/vue": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@unovis/vue/-/vue-1.7.0.tgz", + "integrity": "sha512-kLFvoaf63bnYEGDTfRfHvaJJjpUo2NrqsGJllK1/2S5S3mqBvz9KsYd1j/272Y56YCXhgiNH6dcIS/RzzqwXsA==", + "license": "Apache-2.0", + "peerDependencies": { + "@unovis/ts": "1.7.0", + "vue": "^3" + } + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", @@ -2431,20 +3200,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2649,14 +3404,6 @@ "node": ">= 0.4" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2673,18 +3420,19 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" } }, "node_modules/balanced-match": { @@ -2747,7 +3495,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2778,7 +3526,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2886,18 +3633,13 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, "engines": { - "node": ">= 0.8" + "node": ">= 10" } }, "node_modules/concat-map": { @@ -2945,6 +3687,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, "node_modules/core-js": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", @@ -2956,6 +3704,31 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2970,24 +3743,498 @@ "node": ">= 8" } }, - "node_modules/cssesc": { + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-projection": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz", + "integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==", + "license": "ISC", + "dependencies": { + "commander": "7", + "d3-array": "1 - 3", + "d3-geo": "1.12.0 - 3" + }, + "bin": { + "geo2svg": "bin/geo2svg.js", + "geograticule": "bin/geograticule.js", + "geoproject": "bin/geoproject.js", + "geoquantize": "bin/geoquantize.js", + "geostitch": "bin/geostitch.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate-path": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-interpolate-path/-/d3-interpolate-path-2.3.0.tgz", + "integrity": "sha512-tZYtGXxBmbgHsIc9Wms6LS5u4w6KbP8C09a4/ZYc4KLMYYqub57rRBUgpUr2CIarIrJEpdAWWxWQvofgaMpbKQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" }, "engines": { - "node": ">=4" + "node": ">=12" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3059,7 +4306,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3122,15 +4368,13 @@ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.4.0" + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" } }, "node_modules/detect-libc": { @@ -3168,7 +4412,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3179,6 +4423,18 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/elkjs": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.10.2.tgz", + "integrity": "sha512-Yx3ORtbAFrXelYkAy2g0eYyVY8QG0XEmGdQXmy0eithKKjbWRfl3Xe884lfkszfBF6UKyIy4LwfcZ3AZc8oxFw==", + "license": "EPL-2.0" + }, "node_modules/embla-carousel": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", @@ -3266,6 +4522,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -3339,7 +4604,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3349,7 +4614,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3359,7 +4624,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3372,7 +4637,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3440,7 +4705,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4055,6 +5319,12 @@ "node": ">=8" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4093,28 +5363,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -4131,24 +5379,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4167,7 +5397,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4214,6 +5443,15 @@ "node": ">= 0.4" } }, + "node_modules/geojson": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/geojson/-/geojson-0.5.0.tgz", + "integrity": "sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4241,7 +5479,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4266,7 +5504,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4307,6 +5545,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4354,7 +5598,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4426,7 +5670,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4439,7 +5683,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -4455,7 +5699,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "devOptional": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4483,19 +5726,16 @@ "node": ">=12.0.0" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "agent-base": "6", - "debug": "4" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">= 6" + "node": ">=0.10.0" } }, "node_modules/ignore": { @@ -4512,7 +5752,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -4550,6 +5789,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4568,6 +5816,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -4648,7 +5902,6 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -4975,6 +6228,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -4988,6 +6247,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4995,6 +6266,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5009,6 +6286,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", @@ -5022,6 +6305,12 @@ "json5": "lib/cli.js" } }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5099,6 +6388,12 @@ "vue": "^3.5.13" } }, + "node_modules/leaflet": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.7.1.tgz", + "integrity": "sha512-/xwPEBidtg69Q3HlqPdU3DnrXQOvQU/CCHA1tcDQVzOwm91YMYaILjNp7L4Eaw5Z4sOYdbBz6koWyibppd8Zqw==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5362,6 +6657,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5385,6 +6686,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5407,11 +6714,43 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/maplibre-gl": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.7.0.tgz", + "integrity": "sha512-Y1Q1+UP9WXou0DUrnaFdDsoMqiMCWy6aS968IBUaQ2eZi6H1/kPCfpfZOWXFJZbeKMkX9Wo6OFzY/k48qqPf3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.1", + "@maplibre/maplibre-gl-style-spec": "^26.4.1", + "@maplibre/mlt": "^1.2.0", + "@maplibre/vt-pbf": "^4.3.2", + "@types/geojson": "^7946.0.16", + "earcut": "^3.2.3", + "gl-matrix": "^3.4.4", + "kdbush": "^4.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^5.1.2", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5441,31 +6780,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -5486,7 +6800,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5496,7 +6809,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, "license": "MIT" }, "node_modules/muggle-string": { @@ -5506,6 +6818,12 @@ "dev": true, "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -5735,7 +7053,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -5744,6 +7061,24 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -5774,9 +7109,29 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/php-parser": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/php-parser/-/php-parser-3.2.2.tgz", @@ -5921,6 +7276,12 @@ "web-vitals": "^5.1.0" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/preact": { "version": "10.29.0", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", @@ -6085,16 +7446,11 @@ "node": ">=12.0.0" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - } + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" }, "node_modules/punycode": { "version": "2.3.1", @@ -6143,6 +7499,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -6268,7 +7630,6 @@ "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -6289,7 +7650,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -6305,6 +7665,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -6316,6 +7685,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -6379,6 +7754,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -6444,6 +7825,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -6648,6 +8035,15 @@ "node": ">=10.0.0" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6797,6 +8193,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/striptags": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/striptags/-/striptags-3.2.0.tgz", + "integrity": "sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", + "dependencies": { + "kdbush": "^3.0.0" + } + }, + "node_modules/supercluster/node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC" + }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -6814,7 +8237,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6853,6 +8275,21 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/three": { + "version": "0.135.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.135.0.tgz", + "integrity": "sha512-kuEpuuxRzLv0MDsXai9huCxOSQPZ4vje6y0gn80SRmQvgz6/+rI0NAvCRAw56zYaWKMGMfqKWsxF9Qa2Z9xymQ==", + "license": "MIT" + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6898,6 +8335,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6911,6 +8354,26 @@ "node": ">=8.0" } }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/topojson-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", diff --git a/package.json b/package.json index 840cc7dec..0b6da400e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "@inertiajs/vue3": "^3.6.1", "@tabler/icons-vue": "^3.36.1", "@tailwindcss/typography": "^0.5.19", + "@unovis/ts": "^1.7.0", + "@unovis/vue": "^1.7.0", "@vueuse/core": "^12.8.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/resources/js/components/analytics/AnalyticsAccountSelector.vue b/resources/js/components/analytics/AnalyticsAccountSelector.vue deleted file mode 100644 index aa1125a95..000000000 --- a/resources/js/components/analytics/AnalyticsAccountSelector.vue +++ /dev/null @@ -1,125 +0,0 @@ - - - diff --git a/resources/js/components/analytics/FacebookAnalytics.vue b/resources/js/components/analytics/FacebookAnalytics.vue deleted file mode 100644 index 943e0480f..000000000 --- a/resources/js/components/analytics/FacebookAnalytics.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/resources/js/components/analytics/GoogleBusinessAnalytics.vue b/resources/js/components/analytics/GoogleBusinessAnalytics.vue deleted file mode 100644 index bdbe88692..000000000 --- a/resources/js/components/analytics/GoogleBusinessAnalytics.vue +++ /dev/null @@ -1,108 +0,0 @@ - - - diff --git a/resources/js/components/analytics/InstagramAnalytics.vue b/resources/js/components/analytics/InstagramAnalytics.vue deleted file mode 100644 index 943e0480f..000000000 --- a/resources/js/components/analytics/InstagramAnalytics.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/resources/js/components/analytics/LinkedInPageAnalytics.vue b/resources/js/components/analytics/LinkedInPageAnalytics.vue deleted file mode 100644 index 943e0480f..000000000 --- a/resources/js/components/analytics/LinkedInPageAnalytics.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/resources/js/components/analytics/MetricsGrid.vue b/resources/js/components/analytics/MetricsGrid.vue deleted file mode 100644 index 6b0323fbb..000000000 --- a/resources/js/components/analytics/MetricsGrid.vue +++ /dev/null @@ -1,138 +0,0 @@ - - - diff --git a/resources/js/components/analytics/PinterestAnalytics.vue b/resources/js/components/analytics/PinterestAnalytics.vue deleted file mode 100644 index 943e0480f..000000000 --- a/resources/js/components/analytics/PinterestAnalytics.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/resources/js/components/analytics/TelegramAnalytics.vue b/resources/js/components/analytics/TelegramAnalytics.vue deleted file mode 100644 index 44994bffb..000000000 --- a/resources/js/components/analytics/TelegramAnalytics.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - diff --git a/resources/js/components/analytics/ThreadsAnalytics.vue b/resources/js/components/analytics/ThreadsAnalytics.vue deleted file mode 100644 index 943e0480f..000000000 --- a/resources/js/components/analytics/ThreadsAnalytics.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/resources/js/components/analytics/TikTokAnalytics.vue b/resources/js/components/analytics/TikTokAnalytics.vue deleted file mode 100644 index a8482991f..000000000 --- a/resources/js/components/analytics/TikTokAnalytics.vue +++ /dev/null @@ -1,50 +0,0 @@ - - - diff --git a/resources/js/components/analytics/XAnalytics.vue b/resources/js/components/analytics/XAnalytics.vue deleted file mode 100644 index 943e0480f..000000000 --- a/resources/js/components/analytics/XAnalytics.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/resources/js/components/analytics/YouTubeAnalytics.vue b/resources/js/components/analytics/YouTubeAnalytics.vue deleted file mode 100644 index 943e0480f..000000000 --- a/resources/js/components/analytics/YouTubeAnalytics.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/resources/js/components/analytics/types.ts b/resources/js/components/analytics/types.ts deleted file mode 100644 index 0cf0bb822..000000000 --- a/resources/js/components/analytics/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface AnalyticsAccount { - id: string; - platform: string; - username: string | null; - display_label: string; - avatar_url: string | null; -} diff --git a/resources/js/components/analytics/workspace/AccountIdentity.vue b/resources/js/components/analytics/workspace/AccountIdentity.vue new file mode 100644 index 000000000..674a541b3 --- /dev/null +++ b/resources/js/components/analytics/workspace/AccountIdentity.vue @@ -0,0 +1,33 @@ + + + diff --git a/resources/js/components/analytics/workspace/AnalyticsModeToggle.vue b/resources/js/components/analytics/workspace/AnalyticsModeToggle.vue new file mode 100644 index 000000000..69528e694 --- /dev/null +++ b/resources/js/components/analytics/workspace/AnalyticsModeToggle.vue @@ -0,0 +1,36 @@ + + + diff --git a/resources/js/components/analytics/workspace/AnalyticsSection.vue b/resources/js/components/analytics/workspace/AnalyticsSection.vue new file mode 100644 index 000000000..d023bd3b2 --- /dev/null +++ b/resources/js/components/analytics/workspace/AnalyticsSection.vue @@ -0,0 +1,38 @@ + + + diff --git a/resources/js/components/analytics/workspace/FollowersChart.vue b/resources/js/components/analytics/workspace/FollowersChart.vue new file mode 100644 index 000000000..8a41efac0 --- /dev/null +++ b/resources/js/components/analytics/workspace/FollowersChart.vue @@ -0,0 +1,134 @@ + + + diff --git a/resources/js/components/analytics/workspace/ImportCoverage.vue b/resources/js/components/analytics/workspace/ImportCoverage.vue new file mode 100644 index 000000000..1ebfd4245 --- /dev/null +++ b/resources/js/components/analytics/workspace/ImportCoverage.vue @@ -0,0 +1,30 @@ + + + diff --git a/resources/js/components/analytics/workspace/PerformanceTable.vue b/resources/js/components/analytics/workspace/PerformanceTable.vue new file mode 100644 index 000000000..62d371be6 --- /dev/null +++ b/resources/js/components/analytics/workspace/PerformanceTable.vue @@ -0,0 +1,130 @@ + + + diff --git a/resources/js/components/analytics/workspace/PostsChart.vue b/resources/js/components/analytics/workspace/PostsChart.vue new file mode 100644 index 000000000..e5d105f2a --- /dev/null +++ b/resources/js/components/analytics/workspace/PostsChart.vue @@ -0,0 +1,84 @@ + + + diff --git a/resources/js/components/analytics/workspace/PublicationMetrics.vue b/resources/js/components/analytics/workspace/PublicationMetrics.vue new file mode 100644 index 000000000..ea85cf604 --- /dev/null +++ b/resources/js/components/analytics/workspace/PublicationMetrics.vue @@ -0,0 +1,198 @@ + + + diff --git a/resources/js/components/analytics/workspace/SummaryCards.vue b/resources/js/components/analytics/workspace/SummaryCards.vue new file mode 100644 index 000000000..04375b9ee --- /dev/null +++ b/resources/js/components/analytics/workspace/SummaryCards.vue @@ -0,0 +1,108 @@ + + + diff --git a/resources/js/components/analytics/workspace/TopPosts.vue b/resources/js/components/analytics/workspace/TopPosts.vue new file mode 100644 index 000000000..381e6bdeb --- /dev/null +++ b/resources/js/components/analytics/workspace/TopPosts.vue @@ -0,0 +1,147 @@ + + + diff --git a/resources/js/components/analytics/workspace/charts/FollowerHistoryLineChart.vue b/resources/js/components/analytics/workspace/charts/FollowerHistoryLineChart.vue new file mode 100644 index 000000000..f64504e19 --- /dev/null +++ b/resources/js/components/analytics/workspace/charts/FollowerHistoryLineChart.vue @@ -0,0 +1,138 @@ + + + diff --git a/resources/js/components/analytics/workspace/charts/PostsOverTimeStackedBarChart.vue b/resources/js/components/analytics/workspace/charts/PostsOverTimeStackedBarChart.vue new file mode 100644 index 000000000..8d49c427d --- /dev/null +++ b/resources/js/components/analytics/workspace/charts/PostsOverTimeStackedBarChart.vue @@ -0,0 +1,140 @@ + + + diff --git a/resources/js/components/analytics/workspace/charts/SocialAccountMetricBarChart.vue b/resources/js/components/analytics/workspace/charts/SocialAccountMetricBarChart.vue new file mode 100644 index 000000000..f59d26a04 --- /dev/null +++ b/resources/js/components/analytics/workspace/charts/SocialAccountMetricBarChart.vue @@ -0,0 +1,122 @@ + + + diff --git a/resources/js/components/analytics/workspace/charts/socialAccountChart.ts b/resources/js/components/analytics/workspace/charts/socialAccountChart.ts new file mode 100644 index 000000000..04468b45c --- /dev/null +++ b/resources/js/components/analytics/workspace/charts/socialAccountChart.ts @@ -0,0 +1,30 @@ +import type { ChartConfig } from '@/components/ui/chart'; +import { + getPlatformLabel, + getPlatformLogo, +} from '@/composables/usePlatformLogo'; +import { formatNumberCompact } from '@/lib/utils'; + +import { accountColor } from '@/lib/analyticsColors'; +import type { AccountIdentityData } from '@/types/analytics'; + +export const socialAccountChartConfig = ( + accounts: AccountIdentityData[], + colors: Record, +): ChartConfig => + Object.fromEntries( + accounts.map((account, index) => [ + `account_${index}`, + { + label: account.username + ? `@${account.username}` + : account.name || getPlatformLabel(account.platform), + color: + colors[account.social_account_key] ?? accountColor(index), + icon: getPlatformLogo(account.platform), + }, + ]), + ); + +export const formatCountTick = (tick: number | Date): string => + typeof tick === 'number' ? formatNumberCompact(tick) : ''; diff --git a/resources/js/components/posts/PostPlatformMetrics.vue b/resources/js/components/posts/PostPlatformMetrics.vue index eb778529b..e882f4080 100644 --- a/resources/js/components/posts/PostPlatformMetrics.vue +++ b/resources/js/components/posts/PostPlatformMetrics.vue @@ -1,163 +1,27 @@ diff --git a/resources/js/components/ui/chart/ChartContainer.vue b/resources/js/components/ui/chart/ChartContainer.vue new file mode 100644 index 000000000..e69383c62 --- /dev/null +++ b/resources/js/components/ui/chart/ChartContainer.vue @@ -0,0 +1,58 @@ + + + diff --git a/resources/js/components/ui/chart/ChartTooltipContent.vue b/resources/js/components/ui/chart/ChartTooltipContent.vue new file mode 100644 index 000000000..20d7283ed --- /dev/null +++ b/resources/js/components/ui/chart/ChartTooltipContent.vue @@ -0,0 +1,58 @@ + + + diff --git a/resources/js/components/ui/chart/index.ts b/resources/js/components/ui/chart/index.ts new file mode 100644 index 000000000..9ebcd13a3 --- /dev/null +++ b/resources/js/components/ui/chart/index.ts @@ -0,0 +1,23 @@ +import { createContext } from 'reka-ui'; +import type { Ref } from 'vue'; + +export { + VisCrosshair as ChartCrosshair, + VisTooltip as ChartTooltip, +} from '@unovis/vue'; +export { default as ChartContainer } from './ChartContainer.vue'; +export { default as ChartTooltipContent } from './ChartTooltipContent.vue'; +export { componentToString } from './utils'; + +export type ChartConfig = Record< + string, + { label: string; color: string; icon?: string } +>; + +interface ChartContext { + id: string; + config: Ref; +} + +export const [useChart, provideChartContext] = + createContext('Chart'); diff --git a/resources/js/components/ui/chart/utils.ts b/resources/js/components/ui/chart/utils.ts new file mode 100644 index 000000000..0667adee8 --- /dev/null +++ b/resources/js/components/ui/chart/utils.ts @@ -0,0 +1,32 @@ +import { h, render, type Component } from 'vue'; + +import { activeLocale } from '@/language'; + +import type { ChartConfig } from '.'; + +export const componentToString = ( + config: ChartConfig, + component: Component, + props: Record = {}, +): ((data: Record, x?: number | Date) => string) | undefined => { + if (typeof document === 'undefined') return undefined; + + const cache = new Map(); + + return (source, x) => { + const data = + 'data' in source && source.data && typeof source.data === 'object' + ? (source.data as Record) + : source; + const key = `${activeLocale.value}-${x}-${JSON.stringify(data)}`; + const cached = cache.get(key); + if (cached) return cached; + + const element = document.createElement('div'); + render(h(component, { ...props, payload: data, config, x }), element); + const content = element.innerHTML; + render(null, element); + cache.set(key, content); + return content; + }; +}; diff --git a/resources/js/components/ui/date-range-picker/DateRangePicker.vue b/resources/js/components/ui/date-range-picker/DateRangePicker.vue index e067cfe84..29d83c9cb 100644 --- a/resources/js/components/ui/date-range-picker/DateRangePicker.vue +++ b/resources/js/components/ui/date-range-picker/DateRangePicker.vue @@ -19,10 +19,14 @@ import { RangeCalendar } from "@/components/ui/range-calendar" import { useCalendarLocale } from "@/composables/useCalendarLocale" import { cn } from "@/lib/utils" import dayjs from "@/dayjs" +import date from "@/date" const props = defineProps<{ modelValue: { start: Date, end: Date } triggerClass?: string + minDate?: Date + maxDate?: Date + disabled?: boolean }>() const emit = defineEmits<{ @@ -53,36 +57,53 @@ const isUpdating = ref(false) const isOpen = ref(false) const { width } = useWindowSize() const numberOfMonths = computed(() => width.value < 640 ? 1 : 2) +const minimum = computed(() => props.minDate ? toCalendarDate(props.minDate) : undefined) +const maximum = computed(() => props.maxDate ? toCalendarDate(props.maxDate) : undefined) +const latestAvailableDay = computed(() => { + const today = dayjs().startOf("day") + const latest = props.maxDate ? dayjs(props.maxDate).startOf("day") : today + return latest.isBefore(today) ? latest : today +}) const range = (start: dayjs.Dayjs, end: dayjs.Dayjs) => ({ start: toCalendarDate(start.toDate()), end: toCalendarDate(end.toDate()), }) -const presetGroups = computed(() => [ +type Preset = { key: string, label: string, getValue: () => { start: CalendarDate, end: CalendarDate } } + +const presetGroups = computed(() => [ [ - { label: trans('common.date_range_picker.today'), getValue: () => range(dayjs(), dayjs()) }, - { label: trans('common.date_range_picker.yesterday'), getValue: () => range(dayjs().subtract(1, "day"), dayjs().subtract(1, "day")) }, + { key: 'today', label: trans('common.date_range_picker.today'), getValue: () => range(dayjs(), dayjs()) }, + { key: 'yesterday', label: trans('common.date_range_picker.yesterday'), getValue: () => range(dayjs().subtract(1, "day"), dayjs().subtract(1, "day")) }, ], [ - { label: trans('common.date_range_picker.last_7_days'), getValue: () => range(dayjs().subtract(6, "day"), dayjs()) }, - { label: trans('common.date_range_picker.last_30_days'), getValue: () => range(dayjs().subtract(29, "day"), dayjs()) }, - { label: trans('common.date_range_picker.last_3_months'), getValue: () => range(dayjs().subtract(3, "month"), dayjs()) }, - { label: trans('common.date_range_picker.last_6_months'), getValue: () => range(dayjs().subtract(6, "month"), dayjs()) }, - { label: trans('common.date_range_picker.last_12_months'), getValue: () => range(dayjs().subtract(12, "month").add(1, "day"), dayjs()) }, + { key: 'last_7_days', label: trans('common.date_range_picker.last_7_days'), getValue: () => range(latestAvailableDay.value.subtract(6, "day"), latestAvailableDay.value) }, + { key: 'last_30_days', label: trans('common.date_range_picker.last_30_days'), getValue: () => range(latestAvailableDay.value.subtract(29, "day"), latestAvailableDay.value) }, + { key: 'last_3_months', label: trans('common.date_range_picker.last_3_months'), getValue: () => range(latestAvailableDay.value.subtract(3, "month"), latestAvailableDay.value) }, + { key: 'last_6_months', label: trans('common.date_range_picker.last_6_months'), getValue: () => range(latestAvailableDay.value.subtract(6, "month"), latestAvailableDay.value) }, + { key: 'last_12_months', label: trans('common.date_range_picker.last_12_months'), getValue: () => range(latestAvailableDay.value.subtract(12, "month").add(1, "day"), latestAvailableDay.value) }, ], [ - { label: trans('common.date_range_picker.this_month'), getValue: () => range(dayjs().startOf("month"), dayjs().endOf("month")) }, - { label: trans('common.date_range_picker.last_month'), getValue: () => range(dayjs().subtract(1, "month").startOf("month"), dayjs().subtract(1, "month").endOf("month")) }, - { label: trans('common.date_range_picker.year_to_date'), getValue: () => range(dayjs().startOf("year"), dayjs()) }, - { label: trans('common.date_range_picker.last_year'), getValue: () => range(dayjs().subtract(1, "year").startOf("year"), dayjs().subtract(1, "year").endOf("year")) }, + { key: 'this_month', label: trans('common.date_range_picker.this_month'), getValue: () => range(dayjs().startOf("month"), dayjs().endOf("month")) }, + { key: 'last_month', label: trans('common.date_range_picker.last_month'), getValue: () => range(dayjs().subtract(1, "month").startOf("month"), dayjs().subtract(1, "month").endOf("month")) }, + { key: 'year_to_date', label: trans('common.date_range_picker.year_to_date'), getValue: () => range(dayjs().startOf("year"), dayjs()) }, + { key: 'last_year', label: trans('common.date_range_picker.last_year'), getValue: () => range(dayjs().subtract(1, "year").startOf("year"), dayjs().subtract(1, "year").endOf("year")) }, ], -]) - -type Preset = { label: string, getValue: () => { start: any, end: any } } +].map(group => group.filter(preset => { + const selected = preset.getValue() + return (!minimum.value || selected.end.compare(minimum.value) >= 0) + && (!maximum.value || selected.start.compare(maximum.value) <= 0) +})).filter(group => group.length > 0)) const applyPreset = (preset: Preset) => { - value.value = preset.getValue() + const selected = preset.getValue() + const clamp = (date: CalendarDate) => { + if (minimum.value && date.compare(minimum.value) < 0) return minimum.value + if (maximum.value && date.compare(maximum.value) > 0) return maximum.value + return date + } + value.value = { start: clamp(selected.start), end: clamp(selected.end) } isOpen.value = false } @@ -122,6 +143,8 @@ watch(